Files
agenciapsilmno/src/layout/melissa/MelissaTimelineHoje.vue
T
Leonardo 9f3a047d6d melissa/cronometro: pre-selecionar paciente + sessionPlan + confirm fechar
MelissaCronometro.abrir() agora aceita opts { pacienteId, autostart,
sessionPlan }. Retorna { opened, alreadyRunning, samePaciente, ... }
pra caller decidir o feedback. Estado sessionPlan { startH, endH }
exibe "Programado: HH:MM – HH:MM" sob o select + badge laranja
"atrasada Xmin" quando hNow > startH. Cronometro NAO auto-ajusta —
analista decide quando comecar/parar. Tick a 30s atualiza atraso.
sessionPlan persiste no localStorage junto com o snapshot.

X agora dispara confirmarFechar(): pede ConfirmDialog quando ha
sessao em andamento OU tempo decorrido nao salvo; fecha direto se
clean. Tooltip mudou pra "Encerrar sem salvar".

Chip minimizado: nome do paciente fica display:none em <md (mobile)
pra nao estourar largura do dock — icone + timer cobrem o essencial.

MelissaTimelineHoje: botao ⏱ overlay no canto sup. direito das pills
(horizontal + vertical) quando ev esta em curso E tem patient_id.
Pulso emerald sutil pra chamar atencao; @click.stop pra nao abrir
o evento. Novo emit iniciar-cronometro(ev).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:41:27 -03:00

886 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
/*
* MelissaTimelineHoje — linha do tempo "Hoje" do MelissaLayout
* ------------------------------------------------------------
* Mostra os eventos do dia em duas modalidades responsivas:
* - Horizontal (lg+): scroll horizontal com pilulas posicionadas
* por hora, eco lateral pra eventos off-screen, cursor "Agora"
* e auto-scroll inicial centrado no horario atual
* - Vertical (< lg): tipo calendario "dia" com slots de 48px/h,
* pilulas absolutas e cursor "Agora" tipo Google Calendar
*
* Tudo deriva-se das props (range de horas, eco, posicoes, status).
* Nao acessa state externo direto — eh um componente puro de
* apresentacao + interacoes locais (scroll, eco-click).
*
* Props:
* - eventos: Array — raw eventosHojeReais (passa direto)
* - now: Date — relogio atual (atualizado pelo pai a cada 1s)
* - workRules: Array — regras semanais da agenda (filtra por dow)
* - agendaSettings: Object|null — config da agenda (range fallback)
* - feriados: Array — feriados do mes (procura entry pra hoje)
* - filtroTipo: String|null — filtro ativo (sessao/supervisao/reuniao)
*
* Emit:
* - evento(ev) — clique numa pilula (pai abre dialog)
* - clear-filter — clique no X do chip de filtro
*/
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps({
eventos: { type: Array, default: () => [] },
now: { type: Date, required: true },
workRules: { type: Array, default: () => [] },
agendaSettings: { type: Object, default: null },
feriados: { type: Array, default: () => [] },
filtroTipo: { type: String, default: null }
});
const emit = defineEmits(['evento', 'clear-filter', 'iniciar-cronometro']);
// Helper exposto no template: mostra o botao ⏱ so quando o evento esta
// em curso E tem patient_id (atividade livre/bloqueio nao tem paciente).
function podeIniciarCrono(ev) {
return isEvEmCurso(ev) && !!ev?.patient_id;
}
// ───────────────────────────────────────────────────────────────
// Range de horas (HORA_INICIO/HORA_FIM) — derivado de:
// 1. workRules do dia da semana atual (se houver)
// 2. agendaSettings (fallback global 0818h)
// Range expande pra incluir eventos fora do expediente — sessao
// excepcional nao some da timeline.
// ───────────────────────────────────────────────────────────────
function _timeStrToHour(s, fallback) {
const str = String(s || '').slice(0, 5);
const [h, m] = str.split(':').map(Number);
if (Number.isFinite(h) && Number.isFinite(m)) return h + m / 60;
return fallback;
}
const todayRules = computed(() => {
const dow = props.now.getDay(); // 0=dom .. 6=sab
return (props.workRules || []).filter((r) => r.dia_semana === dow && r.ativo !== false);
});
const isFolga = computed(() => todayRules.value.length === 0);
const todayFeriado = computed(() => {
const d = props.now;
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
return (props.feriados || []).find((f) => f.data === k) || null;
});
function _baseRange() {
const rules = todayRules.value;
if (rules.length > 0) {
const starts = rules.map((r) => _timeStrToHour(r.hora_inicio, 8));
const ends = rules.map((r) => _timeStrToHour(r.hora_fim, 18));
return { start: Math.min(...starts), end: Math.max(...ends) };
}
const s = props.agendaSettings;
const fbStart = (s?.usar_horario_admin_custom && s?.admin_inicio_visualizacao) || s?.agenda_custom_start || '08:00';
const fbEnd = (s?.usar_horario_admin_custom && s?.admin_fim_visualizacao) || s?.agenda_custom_end || '18:00';
return { start: _timeStrToHour(fbStart, 8), end: _timeStrToHour(fbEnd, 18) };
}
const HORA_INICIO = computed(() => {
const { start } = _baseRange();
const minEv = props.eventos.length ? Math.min(...props.eventos.map((e) => e.startH)) : Infinity;
return Math.max(0, Math.floor(Math.min(start, minEv)));
});
const HORA_FIM = computed(() => {
const { end } = _baseRange();
const maxEv = props.eventos.length ? Math.max(...props.eventos.map((e) => e.endH)) : -Infinity;
return Math.min(24, Math.ceil(Math.max(end, maxEv)));
});
const hoursRange = computed(() => {
const arr = [];
for (let h = HORA_INICIO.value; h <= HORA_FIM.value; h++) arr.push(h);
return arr;
});
// ───────────────────────────────────────────────────────────────
// Filtro + lista visivel + label do chip
// ───────────────────────────────────────────────────────────────
const eventosVisiveis = computed(() => {
if (!props.filtroTipo) return props.eventos;
return props.eventos.filter((ev) => ev.tipo === props.filtroTipo);
});
const filtroLabel = computed(() => {
if (!props.filtroTipo) return '';
const map = { sessao: 'atendimentos', supervisao: 'supervisões', reuniao: 'reuniões' };
return map[props.filtroTipo] || '';
});
// ───────────────────────────────────────────────────────────────
// Helpers de status (cor, icone, em-curso) usados em pilulas
// ───────────────────────────────────────────────────────────────
function statusKey(ev) {
const s = String(ev?.status || '').toLowerCase();
if (s === 'realizada') return 'realizado';
if (s === 'cancelada') return 'cancelado';
return s || 'agendado';
}
function statusIcon(ev) {
const s = statusKey(ev);
if (s === 'realizado') return 'pi pi-check';
if (s === 'faltou') return 'pi pi-times';
if (s === 'cancelado') return 'pi pi-ban';
if (s === 'remarcar') return 'pi pi-refresh';
return null;
}
function isEvEmCurso(ev) {
const s = statusKey(ev);
if (s === 'realizado' || s === 'cancelado' || s === 'faltou') return false;
const d = props.now;
const h = d.getHours() + d.getMinutes() / 60;
return typeof ev?.startH === 'number' && typeof ev?.endH === 'number'
&& h >= ev.startH && h < ev.endH;
}
function pillStatusClass(ev) {
const s = statusKey(ev);
return [
`tl-pill--${s}`,
{ 'tl-pill--em-curso': isEvEmCurso(ev) }
];
}
// ───────────────────────────────────────────────────────────────
// Posicionamento das pilulas (horizontal + vertical) e cursor
// ───────────────────────────────────────────────────────────────
function eventStyle(ev) {
const total = HORA_FIM.value - HORA_INICIO.value;
const left = ((ev.startH - HORA_INICIO.value) / total) * 100;
const width = ((ev.endH - ev.startH) / total) * 100;
return {
left: `${left}%`,
width: `${width}%`,
backgroundColor: ev.color,
// Expoe a cor pra CSS (glow/pulse usa color-mix com essa var)
'--ev-color': ev.color
};
}
const VT_HOUR_PX = 48; // altura de cada slot de hora em px
function eventStyleVertical(ev) {
return {
top: `${(ev.startH - HORA_INICIO.value) * VT_HOUR_PX}px`,
height: `${(ev.endH - ev.startH) * VT_HOUR_PX}px`,
backgroundColor: ev.color,
'--ev-color': ev.color
};
}
const nowCursorLeft = computed(() => {
const d = props.now;
const h = d.getHours() + d.getMinutes() / 60;
if (h < HORA_INICIO.value || h > HORA_FIM.value) return '-100%';
const total = HORA_FIM.value - HORA_INICIO.value;
return `${((h - HORA_INICIO.value) / total) * 100}%`;
});
const nowCursorTop = computed(() => {
const d = props.now;
const h = d.getHours() + d.getMinutes() / 60;
if (h < HORA_INICIO.value || h > HORA_FIM.value) return '-100%';
return `${(h - HORA_INICIO.value) * VT_HOUR_PX}px`;
});
function fmtHora(h) {
const horas = Math.floor(h);
const mins = Math.round((h - horas) * 60);
return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
// ───────────────────────────────────────────────────────────────
// Scroll/eco do horizontal — minimap pulsante de cores nas bordas
// ───────────────────────────────────────────────────────────────
const tlHScrollEl = ref(null);
const tlScrollState = ref({ scrollL: 0, viewW: 0, innerW: 0 });
function _updateTlScrollState() {
const el = tlHScrollEl.value;
if (!el) {
tlScrollState.value = { scrollL: 0, viewW: 0, innerW: 0 };
return;
}
const inner = el.firstElementChild;
tlScrollState.value = {
scrollL: el.scrollLeft,
viewW: el.clientWidth,
innerW: inner ? (inner.scrollWidth || inner.offsetWidth) : 0
};
}
function onTimelineScroll() {
_updateTlScrollState();
}
const tlEcoState = computed(() => {
const { scrollL, viewW, innerW } = tlScrollState.value;
const total = HORA_FIM.value - HORA_INICIO.value;
const empty = { left: [], right: [], vStart: HORA_INICIO.value, vEnd: HORA_FIM.value };
if (total <= 0 || !innerW || !viewW || innerW <= viewW) return empty;
const vStart = HORA_INICIO.value + (scrollL / innerW) * total;
const vEnd = HORA_INICIO.value + ((scrollL + viewW) / innerW) * total;
const left = [];
const right = [];
for (const ev of eventosVisiveis.value) {
if (ev.endH <= vStart) left.push(ev);
else if (ev.startH >= vEnd) right.push(ev);
}
return { left, right, vStart, vEnd };
});
// Esquerda: HORA_INICIO -> vStart mapeado 0-100%.
// Direita: vEnd -> HORA_FIM mapeado 0-100%.
function ecoTickStyle(ev, side) {
const { vStart, vEnd } = tlEcoState.value;
let topPct = 50;
if (side === 'left') {
const span = vStart - HORA_INICIO.value;
if (span > 0) topPct = ((ev.startH - HORA_INICIO.value) / span) * 100;
} else {
const span = HORA_FIM.value - vEnd;
if (span > 0) topPct = ((ev.startH - vEnd) / span) * 100;
}
return {
top: `${Math.max(0, Math.min(100, topPct))}%`,
backgroundColor: ev.color
};
}
function scrollToEvent(ev) {
const el = tlHScrollEl.value;
if (!el) return;
const inner = el.firstElementChild;
if (!inner) return;
const innerWidth = inner.scrollWidth || inner.offsetWidth;
const visibleWidth = el.clientWidth;
const total = HORA_FIM.value - HORA_INICIO.value;
if (total <= 0) return;
const ratio = (ev.startH - HORA_INICIO.value) / total;
const target = Math.max(0, Math.min(innerWidth - visibleWidth, ratio * innerWidth - visibleWidth / 2));
el.scrollTo({ left: target, behavior: 'smooth' });
}
// ───────────────────────────────────────────────────────────────
// Auto-scroll inicial: centra "agora" na viewport ao montar (jornadas
// longas ex: 02h-23h nao abrem com cursor off-screen). Roda uma vez.
// ResizeObserver mantem o eco atualizado quando viewport muda.
// ───────────────────────────────────────────────────────────────
let _tlAutoScrolled = false;
function _scrollTimelineToNow() {
const el = tlHScrollEl.value;
if (!el) return;
const d = props.now;
const h = d.getHours() + d.getMinutes() / 60;
const total = HORA_FIM.value - HORA_INICIO.value;
if (total <= 0 || h < HORA_INICIO.value || h > HORA_FIM.value) return;
const inner = el.firstElementChild;
if (!inner) return;
const innerWidth = inner.scrollWidth || inner.offsetWidth;
const visibleWidth = el.clientWidth;
if (innerWidth <= visibleWidth) return; // sem overflow, nada a rolar
const ratio = (h - HORA_INICIO.value) / total;
el.scrollLeft = Math.max(0, ratio * innerWidth - visibleWidth / 2);
}
onMounted(() => {
const stop = watch(
[HORA_INICIO, HORA_FIM],
() => {
nextTick(() => {
if (!_tlAutoScrolled) {
_scrollTimelineToNow();
const el = tlHScrollEl.value;
const inner = el?.firstElementChild;
if (inner && inner.scrollWidth > el.clientWidth) {
_tlAutoScrolled = true;
stop();
}
}
_updateTlScrollState();
});
},
{ immediate: true }
);
const el = tlHScrollEl.value;
if (el && typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(() => _updateTlScrollState());
ro.observe(el);
if (el.firstElementChild) ro.observe(el.firstElementChild);
onBeforeUnmount(() => ro.disconnect());
}
});
</script>
<template>
<section class="glass-panel mt-8 px-5 py-4 max-w-5xl w-full mx-auto">
<!-- Header: titulo, badges (folga/feriado), chip de filtro, "Agora" -->
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2 text-white/90 text-sm font-medium">
<i class="pi pi-clock text-xs" />
Linha do tempo Hoje
<span
v-if="todayFeriado"
class="tl-day-badge tl-day-badge--feriado"
:title="`Feriado: ${todayFeriado.nome}`"
>
<i class="pi pi-star text-[0.6rem]" />
Feriado{{ todayFeriado.nome ? `: ${todayFeriado.nome}` : '' }}
</span>
<span
v-else-if="isFolga"
class="tl-day-badge tl-day-badge--folga"
title="Hoje não é dia de trabalho na sua agenda — sessões fora do expediente continuam permitidas."
>
<i class="pi pi-moon text-[0.6rem]" />
Folga
</span>
<button
v-if="filtroTipo"
type="button"
class="filtro-chip"
:title="`Mostrando apenas ${filtroLabel}. Clique pra mostrar tudo.`"
@click="emit('clear-filter')"
>
{{ filtroLabel }}
<i class="pi pi-times text-[0.6rem]" />
</button>
</div>
<div class="text-xs text-white/70 flex items-center gap-1.5">
<span class="pulse-dot w-2.5 h-0.5 rounded-full bg-red-500" />
Agora
</div>
</div>
<!-- Horizontal (lg+) scroll horizontal com pilulas + cursor "agora" + eco lateral.
Auto-scroll inicial centra "agora" pra jornadas longas (02h-23h). Frame
relativo abriga o eco como overlay absolute. -->
<div class="tl-h-frame hidden lg:block">
<div ref="tlHScrollEl" class="tl-h-scroll" @scroll.passive="onTimelineScroll">
<div class="tl-h-inner relative" :style="{ '--m-tl-cols': HORA_FIM - HORA_INICIO }">
<div class="flex justify-between mb-1">
<div v-for="h in hoursRange" :key="h" class="flex-1 text-left">
<span class="text-[0.65rem] text-white/55 font-medium">{{ h }}h</span>
</div>
</div>
<div class="relative h-9 bg-white/5 rounded-md overflow-visible border border-white/10">
<div
v-for="ev in eventosVisiveis"
:key="ev.id"
class="tl-event-pill absolute h-[30px] rounded flex items-center px-2 overflow-hidden cursor-pointer min-w-[32px] hover:brightness-110 transition-[filter,opacity] duration-200 z-10"
:class="pillStatusClass(ev)"
:style="eventStyle(ev)"
:title="ev.label"
@click="emit('evento', ev)"
>
<span class="tl-event-pill__label text-[0.8rem] font-semibold truncate">{{ ev.label }}</span>
<i
v-if="statusIcon(ev)"
:class="['tl-event-pill__status', statusIcon(ev)]"
aria-hidden="true"
/>
<!-- Botao overlay so em sessoes em curso com
paciente. stopPropagation pra nao disparar
o click do pill que abre o evento. -->
<button
v-if="podeIniciarCrono(ev)"
type="button"
class="tl-event-pill__crono"
title="Iniciar cronômetro"
aria-label="Iniciar cronômetro"
@click.stop="emit('iniciar-cronometro', ev)"
>
<i class="pi pi-stopwatch" />
</button>
</div>
<div
class="absolute top-0 h-full flex flex-col items-center pointer-events-none z-20"
:style="{ left: nowCursorLeft }"
>
<div class="w-0.5 h-full bg-red-500 opacity-90" />
<div class="absolute -top-0.5 w-[7px] h-[7px] rounded-full bg-red-500" />
</div>
</div>
</div>
</div>
<!-- Eco lateral minimap pulsante de cores. Tracinhos posicionados
por tempo, mostrando "forma" do dia off-screen. Click = scroll
suave ate o evento. -->
<div
v-if="tlEcoState.left.length"
class="tl-eco tl-eco--left"
:title="`${tlEcoState.left.length} antes — clique pra centralizar`"
>
<button
v-for="ev in tlEcoState.left"
:key="`eco-l-${ev.id}`"
type="button"
class="tl-eco__tick"
:style="ecoTickStyle(ev, 'left')"
:title="`${fmtHora(ev.startH)} · ${ev.label}`"
@click="scrollToEvent(ev)"
/>
</div>
<div
v-if="tlEcoState.right.length"
class="tl-eco tl-eco--right"
:title="`${tlEcoState.right.length} à frente — clique pra centralizar`"
>
<button
v-for="ev in tlEcoState.right"
:key="`eco-r-${ev.id}`"
type="button"
class="tl-eco__tick"
:style="ecoTickStyle(ev, 'right')"
:title="`${fmtHora(ev.startH)} · ${ev.label}`"
@click="scrollToEvent(ev)"
/>
</div>
</div>
<!-- Vertical (< lg) calendario "dia" empilhado. Slots de 48px/h. -->
<div class="vt lg:hidden" :style="{ '--m-vt-rows': HORA_FIM - HORA_INICIO }">
<div
v-for="h in hoursRange"
:key="h"
class="vt-row"
:style="{ top: `${(h - HORA_INICIO) * VT_HOUR_PX}px` }"
>
<span class="vt-hour">{{ h }}h</span>
<div class="vt-line" />
</div>
<div
v-for="ev in eventosVisiveis"
:key="ev.id"
class="vt-event hover:brightness-110 transition-[filter,opacity] duration-200"
:class="pillStatusClass(ev)"
:style="eventStyleVertical(ev)"
:title="ev.label"
@click="emit('evento', ev)"
>
<i
v-if="statusIcon(ev)"
:class="['vt-event__status', statusIcon(ev)]"
aria-hidden="true"
/>
<div class="vt-event-time">
{{ fmtHora(ev.startH) }} {{ fmtHora(ev.endH) }}
</div>
<div class="vt-event-label">{{ ev.label }}</div>
<!-- Botao overlay so em sessoes em curso com paciente.
stopPropagation pra nao disparar o click do pill. -->
<button
v-if="podeIniciarCrono(ev)"
type="button"
class="vt-event__crono"
title="Iniciar cronômetro"
aria-label="Iniciar cronômetro"
@click.stop="emit('iniciar-cronometro', ev)"
>
<i class="pi pi-stopwatch" />
</button>
</div>
<div class="vt-now" :style="{ top: nowCursorTop }">
<div class="vt-now-dot" />
<div class="vt-now-line" />
</div>
</div>
</section>
</template>
<style scoped>
/* glass-panel base — redeclarado scoped (o do pai esta em scoped tambem
e nao atravessa). Overrides light mode (.win11-root :is(.glass-panel...))
estao em <style> nao-scoped do pai e continuam aplicando. */
.glass-panel {
background: var(--m-bg-soft);
backdrop-filter: blur(24px) saturate(140%);
-webkit-backdrop-filter: blur(24px) saturate(140%);
border: 1px solid var(--m-border);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
/* ─── Chip do filtro ativo (header da timeline) ────────────── */
.filtro-chip {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 6px;
padding: 2px 8px 2px 10px;
border-radius: 9999px;
background: var(--m-bg-soft-hover);
border: 1px solid var(--m-border-strong);
color: var(--m-text);
font-size: 0.7rem;
font-weight: 500;
cursor: pointer;
transition: background-color 140ms ease;
}
.filtro-chip:hover {
background: var(--m-border-strong);
}
/* ─── Badge "Folga" / "Feriado" ──────────────────────────────
Sinaliza dia nao-util sem bloquear: sessoes fora do expediente
continuam permitidas e visiveis na timeline. */
.tl-day-badge {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 6px;
padding: 2px 8px;
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
}
.tl-day-badge--folga {
background: var(--m-bg-soft-hover);
color: var(--m-text-muted);
border: 1px solid var(--m-border-strong);
}
.tl-day-badge--feriado {
background: color-mix(in srgb, rgb(245, 158, 11) 18%, transparent);
color: rgb(245, 158, 11);
border: 1px solid color-mix(in srgb, rgb(245, 158, 11) 38%, transparent);
}
/* Light mode: amber-600 saturado ficaria estridente; baixa pra amber-700
sobre amber-50 — legivel em fundo claro e mantem a identidade do feriado.
Em scoped CSS, ancestor `html:not(.app-dark)` nao recebe o hash do
componente — so o leaf `.tl-day-badge--feriado` recebe; o selector
funciona normalmente. */
html:not(.app-dark) .tl-day-badge--feriado {
background: color-mix(in srgb, rgb(217, 119, 6) 12%, transparent);
color: rgb(180, 83, 9);
border-color: color-mix(in srgb, rgb(217, 119, 6) 32%, transparent);
}
/* ─── Timeline vertical (< lg) — tipo calendario "dia" ─────── */
.vt {
position: relative;
/* --m-vt-rows = HORA_FIM - HORA_INICIO (set inline; fallback 12 = 8-20) */
height: calc(var(--m-vt-rows, 12) * 48px + 24px);
margin-top: 0.75rem;
}
.vt-row {
position: absolute;
left: 0;
right: 0;
height: 0;
display: flex;
align-items: center;
}
.vt-hour {
width: 36px;
text-align: right;
padding-right: 8px;
color: var(--m-text-muted);
font-size: 0.65rem;
font-weight: 500;
flex-shrink: 0;
/* Texto centrado na linha (transform shift up half) */
transform: translateY(-50%);
background: transparent;
line-height: 1;
}
.vt-line {
flex: 1;
height: 1px;
background: var(--m-bg-soft);
}
.vt-event {
position: absolute;
left: 44px;
right: 4px;
padding: 4px 10px;
border-radius: 4px;
color: white;
cursor: pointer;
overflow: hidden;
z-index: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 24px;
}
.vt-event-time {
font-size: 0.62rem;
opacity: 0.85;
line-height: 1.1;
}
.vt-event-label {
font-size: 0.78rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
/* ─── Pilula (horizontal) — label sempre branco sobre cor saturada.
Override .text-white global flipava em light mode pra dark e quebrava
contraste sobre indigo/verde/vermelho — forcar branco aqui. */
.tl-event-pill__label {
color: #fff;
flex: 1;
min-width: 0;
}
/* ─── Status overlays nas pilulas (horizontal + vertical) ──────
Cada status ganha tratamento especifico — icone no canto + variacao
de opacidade/borda/hatch. var --ev-color (set no inline style do
eventStyle) alimenta o pulse "em curso" com a cor do proprio evento. */
.tl-event-pill {
transition: filter 200ms ease, opacity 200ms ease, box-shadow 240ms ease;
}
.tl-event-pill__status,
.vt-event__status {
flex-shrink: 0;
display: inline-grid;
place-items: center;
width: 14px;
height: 14px;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.28);
color: #fff;
font-size: 0.55rem;
margin-left: 6px;
line-height: 1;
}
.vt-event__status {
position: absolute;
top: 4px;
right: 4px;
width: 16px;
height: 16px;
font-size: 0.6rem;
margin-left: 0;
}
/* Botao ⏱ "Iniciar cronometro" — overlay no canto sup. direito do
pill em sessoes em curso. Cor solida pra destacar contra o bg
colorido do evento; pulso sutil pra chamar atencao sem irritar. */
.tl-event-pill__crono,
.vt-event__crono {
position: absolute;
top: 3px;
right: 3px;
width: 22px;
height: 22px;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.45);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 9999px;
cursor: pointer;
font-family: inherit;
padding: 0;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
transition: background-color 160ms ease, transform 160ms ease, border-color 160ms ease;
animation: tl-crono-pulse 2s ease-in-out infinite;
z-index: 2;
}
.tl-event-pill__crono:hover,
.vt-event__crono:hover {
background: rgba(16, 185, 129, 0.85); /* emerald-500 — convida ao "play" */
border-color: rgba(255, 255, 255, 0.6);
transform: scale(1.08);
animation-play-state: paused;
}
.tl-event-pill__crono > i,
.vt-event__crono > i {
font-size: 0.7rem;
}
@keyframes tl-crono-pulse {
0%, 100% { box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35), 0 0 0 0 rgba(16, 185, 129, 0.45); }
50% { box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35), 0 0 0 6px rgba(16, 185, 129, 0); }
}
/* Realizado: glow verde sutil (cor do bg ja eh verde) — celebra o feito */
.tl-pill--realizado {
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.28), 0 4px 12px rgba(16, 185, 129, 0.18);
}
/* Faltou: opacidade reduzida + label tachado */
.tl-pill--faltou {
opacity: 0.78;
}
.tl-pill--faltou .tl-event-pill__label,
.tl-pill--faltou .vt-event-label {
text-decoration: line-through;
text-decoration-color: rgba(255, 255, 255, 0.55);
text-decoration-thickness: 1.5px;
}
/* Cancelado: hatching diagonal + opacidade + label tachado */
.tl-pill--cancelado {
opacity: 0.6;
background-image: repeating-linear-gradient(
135deg,
rgba(255, 255, 255, 0.0) 0,
rgba(255, 255, 255, 0.0) 4px,
rgba(255, 255, 255, 0.22) 4px,
rgba(255, 255, 255, 0.22) 6px
);
}
.tl-pill--cancelado .tl-event-pill__label,
.tl-pill--cancelado .vt-event-label {
text-decoration: line-through;
text-decoration-color: rgba(255, 255, 255, 0.5);
}
/* Remarcar: ring ambar puxa atencao (status transiente, precisa decisao) */
.tl-pill--remarcar {
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.6), 0 4px 14px rgba(245, 158, 11, 0.25);
}
/* Em curso: pulse glow na cor do proprio evento via --ev-color */
.tl-pill--em-curso {
animation: tl-pill-em-curso 2.2s ease-in-out infinite;
z-index: 12;
}
@keyframes tl-pill-em-curso {
0%, 100% {
box-shadow:
0 0 0 0 color-mix(in srgb, var(--ev-color, #6366f1) 55%, transparent),
0 0 0 1px rgba(255, 255, 255, 0.14);
}
50% {
box-shadow:
0 0 0 6px color-mix(in srgb, var(--ev-color, #6366f1) 0%, transparent),
0 0 18px 2px color-mix(in srgb, var(--ev-color, #6366f1) 60%, transparent);
}
}
/* ─── Timeline horizontal: scroll quando o range eh grande ────
--m-tl-cols (set inline) = HORA_FIM - HORA_INICIO
--m-tl-slot-w = largura minima por hora (default 80px). */
.tl-h-scroll {
overflow-x: auto;
overflow-y: visible;
/* respiro pra pilulas e cursor "Agora" nao cortarem na borda */
padding-bottom: 4px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.tl-h-scroll::-webkit-scrollbar { height: 6px; }
.tl-h-scroll::-webkit-scrollbar-track { background: transparent; }
.tl-h-scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 9999px;
}
.tl-h-inner {
min-width: calc(var(--m-tl-cols, 12) * var(--m-tl-slot-w, 80px));
}
/* ─── Eco lateral: minimap pulsante de cores nas bordas ────────
Faixas verticais de 8px coladas nas bordas do scroll, mostrando
tracinhos coloridos (cor do status) pra cada evento off-screen. */
.tl-h-frame { position: relative; }
.tl-eco {
position: absolute;
top: 16px; /* alinha com topo da barra (descontando linha de horas) */
bottom: 8px; /* respiro do scrollbar */
width: 8px;
z-index: 6;
pointer-events: auto;
border-radius: 4px;
background: color-mix(in srgb, var(--m-bg-soft) 70%, transparent);
border: 1px solid var(--m-border);
box-shadow: 0 0 0 0 transparent;
animation: tl-eco-pulse 2400ms ease-in-out infinite;
transition: opacity 180ms ease;
}
.tl-eco--left { left: -2px; }
.tl-eco--right { right: -2px; }
.tl-eco__tick {
position: absolute;
left: 1px;
right: 1px;
height: 4px;
transform: translateY(-50%);
border: 0;
padding: 0;
border-radius: 2px;
cursor: pointer;
opacity: 0.85;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15);
transition: opacity 140ms ease, transform 140ms ease, height 140ms ease;
}
.tl-eco__tick:hover {
opacity: 1;
height: 6px;
transform: translateY(-50%) scaleX(2.2);
z-index: 1;
}
.tl-eco--left .tl-eco__tick:hover { transform-origin: left center; }
.tl-eco--right .tl-eco__tick:hover { transform-origin: right center; }
@keyframes tl-eco-pulse {
0%, 100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--p-primary-color) 0%, transparent);
}
50% {
box-shadow: 0 0 10px 1px color-mix(in srgb, var(--p-primary-color) 35%, transparent);
}
}
/* Light mode: faixa precisa contrastar mais com bloom claro */
html:not(.app-dark) .tl-eco {
background: color-mix(in srgb, var(--m-bg-soft-hover) 90%, transparent);
border-color: var(--m-border-strong);
}
/* ─── Cursor "Agora" do vertical (mobile) ─────────────────── */
.vt-now {
position: absolute;
left: 32px;
right: 0;
z-index: 2;
pointer-events: none;
display: flex;
align-items: center;
transform: translateY(-50%);
}
.vt-now-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgb(239, 68, 68);
flex-shrink: 0;
animation: pulse 1.6s ease-in-out infinite;
}
.vt-now-line {
flex: 1;
height: 2px;
background: rgb(239, 68, 68);
box-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
margin-left: 2px;
}
/* ─── Pulse no "Agora" (header) ────────────────────────────── */
.pulse-dot {
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>