diff --git a/frontend/src/__apps/painel/store/painelStore.js b/frontend/src/__apps/painel/store/painelStore.js index 7cf629e29..44072bca6 100644 --- a/frontend/src/__apps/painel/store/painelStore.js +++ b/frontend/src/__apps/painel/store/painelStore.js @@ -51,6 +51,7 @@ export const usePainelStore = defineStore('painel', { this.painel_aberto = next.painel_aberto this.mostrar_voto = next.mostrar_voto this.message = next.message + this.cronometro_ativo = next.cronometro_ativo // objeto aninhado: patch (preserva reatividade dos campos) Object.assign(this.sessao, next.sessao) diff --git a/frontend/src/__apps/painel/ws/normalizePainel.js b/frontend/src/__apps/painel/ws/normalizePainel.js index 116618960..8f4c73de7 100644 --- a/frontend/src/__apps/painel/ws/normalizePainel.js +++ b/frontend/src/__apps/painel/ws/normalizePainel.js @@ -23,10 +23,11 @@ export const DEFAULT_STATE = Object.freeze({ brasao: '' }, - cronometro_discurso: '', - cronometro_aparte: '', - cronometro_ordem: '', - cronometro_consideracoes: '', + cronometro_discurso: null, + cronometro_aparte: null, + cronometro_ordem: null, + cronometro_consideracoes: null, + cronometro_ativo: 'discurso', // Só significa algo enquanto há matéria nominal aberta pra registro — // ver PainelConsumer (type: "registro_toggle") / VotacaoNominal.vue. @@ -58,6 +59,46 @@ function asString (v, fallback) { return v != null ? v : fallback } +/** + * Normaliza o valor do cronômetro recebido do backend. + * + * O backend novo envia um dict: + * { action, duration, start_ts?, remaining_at_stop? } + * + * O backend antigo (cache legado) pode enviar uma string ('start'|'stop'|'reset'). + * O valor null/undefined significa "sem estado" (nunca foi configurado). + * + * Retorna sempre um objeto normalizado ou null. + */ +function normalizeCronometro (v) { + if (v == null) return null + // Formato legado: string simples + if (typeof v === 'string') { + return { + action: v, + duration: 300, + start_ts: null, + remaining_at_start: 300, + remaining_at_stop: null, + updated_at: null, + } + } + if (typeof v === 'object') { + const duration = v.duration != null ? v.duration : 300 + const remaining_at_start = v.remaining_at_start != null ? v.remaining_at_start : duration + const remaining_at_stop = v.remaining_at_stop != null ? v.remaining_at_stop : null + return { + action: v.action || '', + duration: duration, + start_ts: v.start_ts != null ? v.start_ts : null, + remaining_at_start: remaining_at_start, + remaining_at_stop: remaining_at_stop, + updated_at: v.updated_at != null ? v.updated_at : null, + } + } + return null +} + export function normalizePainelData (raw) { const d = asObject(raw) @@ -90,7 +131,7 @@ export function normalizePainelData (raw) { // contam como "iniciada" — sessões legadas ficaram com None, mesma // convenção de restringe_sessoes_visiveis() no backend). sessao_aberta: !!d.sessao_iniciada && !d.sessao_finalizada, - painel_aberto: d.status_painel != null ? !!d.status_painel : DEFAULT_STATE.painel_aberto, + painel_aberto: d.status_painel != null ? !!d.status_painel : DEFAULT_STATE.painel_aberto, mostrar_voto: d.mostrar_voto != null ? !!d.mostrar_voto : DEFAULT_STATE.mostrar_voto, message: asString(d.msg_painel, DEFAULT_STATE.message), @@ -103,10 +144,14 @@ export function normalizePainelData (raw) { brasao: asString(d.brasao, DEFAULT_STATE.sessao.brasao) }, - cronometro_discurso: asString(d.cronometro_discurso, DEFAULT_STATE.cronometro_discurso), - cronometro_aparte: asString(d.cronometro_aparte, DEFAULT_STATE.cronometro_aparte), - cronometro_ordem: asString(d.cronometro_ordem, DEFAULT_STATE.cronometro_ordem), - cronometro_consideracoes: asString(d.cronometro_consideracoes, DEFAULT_STATE.cronometro_consideracoes), + // Cronômetros: agora objetos {action, duration, start_ts?, remaining_at_stop?} + // ou null quando nunca configurados. O store repassa para CronometroList.vue + // que aplica a ação e configura o tempo inicial de cada Cronometro.vue. + cronometro_discurso: normalizeCronometro(d.cronometro_discurso), + cronometro_aparte: normalizeCronometro(d.cronometro_aparte), + cronometro_ordem: normalizeCronometro(d.cronometro_ordem), + cronometro_consideracoes: normalizeCronometro(d.cronometro_consideracoes), + cronometro_ativo: asString(d.cronometro_ativo, DEFAULT_STATE.cronometro_ativo), registro_aberto: d.registro_aberto != null ? !!d.registro_aberto : DEFAULT_STATE.registro_aberto, diff --git a/frontend/src/components/painel/Cronometro.vue b/frontend/src/components/painel/Cronometro.vue index be683cf94..f79ec9c07 100644 --- a/frontend/src/components/painel/Cronometro.vue +++ b/frontend/src/components/painel/Cronometro.vue @@ -40,13 +40,71 @@ export default { fontSize = parseFloat(fontSize); el.style.fontSize = (fontSize + value) + 'px'; }, - // start/stop/reset são idempotentes de propósito: o valor cacheado no - // servidor (sapl.painel.views.cronometro_painel) é reenviado em todo - // broadcast do painel, não só quando o cronômetro muda — chamar - // start() de novo com o cronômetro já rodando não deve reiniciar a - // contagem, e stop()/reset() repetidos não devem quebrar nada. - start() { - if (this.isRunning) return; + + /** + * Aplica o estado completo recebido do backend. + * + * @param {Object} state - { action, duration, start_ts?, remaining_at_stop? } + * + * Lógica: + * - reset / sem estado → para, define time = duration + * - stop → para, define time = remaining_at_stop (tempo no momento do stop) + * - start → calcula tempo restante a partir de start_ts + duration - agora + * e inicia a contagem (idempotente: se já estiver rodando com + * o mesmo start_ts, não reinicia) + */ + applyState(state) { + if (!state || !state.action) { + // Sem estado: apenas configura a duração e para + this.stop(); + this.time = this.initialTime; + return; + } + + const { action, duration, start_ts, remaining_at_start, remaining_at_stop } = state; + + // Atualiza a duração configurada + if (duration != null) { + this.initialTime = duration; + } + + if (action === 'reset') { + this.stop(); + this.time = this.initialTime; + + } else if (action === 'stop') { + this.stop(); + // Mostra o tempo exato em que o operador parou o cronômetro + if (remaining_at_stop != null) { + this.time = Math.max(0, Math.round(remaining_at_stop)); + } + + } else if (action === 'start') { + // Calcula o tempo restante a partir de remaining_at_start e start_ts + const nowSec = Date.now() / 1000; + const elapsed = start_ts != null ? Math.max(0, nowSec - start_ts) : 0; + const baseRem = remaining_at_start != null + ? remaining_at_start + : (remaining_at_stop != null + ? remaining_at_stop + : (this.time > 0 && this.time < this.initialTime ? this.time : this.initialTime)); + const remaining = Math.max(0, baseRem - elapsed); + + // Idempotente: se já estiver rodando e a contagem estiver em sincronia + // (diferença <= 2s), mantém o intervalo nativo para não ter saltos + if (!this.isRunning || Math.abs(this.time - remaining) > 2) { + this.stop(); + this.time = Math.round(remaining); + if (remaining > 0) { + this._startInterval(); + } + } + } + + this.$emit('state-changed', { id: this.id, isRunning: this.isRunning }); + }, + + _startInterval() { this.isRunning = true; this.intervalId = setInterval(() => { if (this.time > 0) { @@ -58,13 +116,22 @@ export default { this.isRunning = false; clearInterval(this.intervalId); this.playSound(); + this.$emit('state-changed', { id: this.id, isRunning: false }); } }, 1000); }, + // start/stop/reset legados mantidos para compatibilidade com qualquer + // chamador externo, mas internamente agora delegam para applyState(). + start() { + if (this.isRunning) return; + this._startInterval(); + }, + stop() { this.isRunning = false; clearInterval(this.intervalId); + this.intervalId = null; }, reset() { diff --git a/frontend/src/components/painel/CronometroList.vue b/frontend/src/components/painel/CronometroList.vue index 0f017e32a..104f35b3a 100644 --- a/frontend/src/components/painel/CronometroList.vue +++ b/frontend/src/components/painel/CronometroList.vue @@ -1,4 +1,9 @@