fix: reconcile ticket timer with server start

This commit is contained in:
Esdras Renan 2025-10-19 19:52:42 -03:00
parent 1df7e13c8f
commit 3b5676ed35
3 changed files with 96 additions and 5 deletions

View file

@ -31,6 +31,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { reconcileLocalSessionStart, type SessionStartOrigin } from "./ticket-timer.utils"
interface TicketHeaderProps { interface TicketHeaderProps {
ticket: TicketWithDetails ticket: TicketWithDetails
@ -390,6 +391,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
// Guarda um marcador local do início da sessão atual para evitar inflar tempo com // Guarda um marcador local do início da sessão atual para evitar inflar tempo com
// timestamps defasados vindos da rede. Escolhemos o MAIOR entre (remoto, local). // timestamps defasados vindos da rede. Escolhemos o MAIOR entre (remoto, local).
const localStartAtRef = useRef<number>(0) const localStartAtRef = useRef<number>(0)
const localStartOriginRef = useRef<SessionStartOrigin>("unknown")
useEffect(() => { useEffect(() => {
if (!workSummary?.activeSession) return if (!workSummary?.activeSession) return
@ -403,12 +405,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
useEffect(() => { useEffect(() => {
if (!workSummary?.activeSession) { if (!workSummary?.activeSession) {
localStartAtRef.current = 0 localStartAtRef.current = 0
localStartOriginRef.current = "unknown"
return return
} }
const remoteStart = Number(workSummary.activeSession.startedAt) || 0 const { localStart, origin } = reconcileLocalSessionStart({
if (remoteStart > localStartAtRef.current) { remoteStart: Number(workSummary.activeSession.startedAt) || 0,
localStartAtRef.current = remoteStart localStart: localStartAtRef.current,
} origin: localStartOriginRef.current,
})
localStartAtRef.current = localStart
localStartOriginRef.current = origin
}, [workSummary?.activeSession?.id, workSummary?.activeSession?.startedAt]) }, [workSummary?.activeSession?.id, workSummary?.activeSession?.startedAt])
useEffect(() => { useEffect(() => {
@ -446,13 +452,21 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
toast.loading("Iniciando atendimento...", { id: "work" }) toast.loading("Iniciando atendimento...", { id: "work" })
try { try {
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users">, workType }) const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users">, workType })
if (result?.status === "already_started") { const status = (result as { status?: string } | null)?.status ?? "started"
if (status === "already_started") {
toast.info("O atendimento já estava em andamento", { id: "work" }) toast.info("O atendimento já estava em andamento", { id: "work" })
} else { } else {
toast.success("Atendimento iniciado", { id: "work" }) toast.success("Atendimento iniciado", { id: "work" })
} }
// Otimização local: garantir startedAt correto imediatamente // Otimização local: garantir startedAt correto imediatamente
const startedAtMs = typeof result?.startedAt === "number" ? result.startedAt : Date.now() const startedAtMs = typeof result?.startedAt === "number" ? result.startedAt : Date.now()
if (typeof result?.startedAt === "number") {
localStartOriginRef.current = "remote"
} else if (status === "already_started") {
localStartOriginRef.current = "already-running-fallback"
} else {
localStartOriginRef.current = "fresh-local"
}
localStartAtRef.current = startedAtMs localStartAtRef.current = startedAtMs
const sessionId = (result as { sessionId?: unknown })?.sessionId as Id<"ticketWorkSessions"> | undefined const sessionId = (result as { sessionId?: unknown })?.sessionId as Id<"ticketWorkSessions"> | undefined
setWorkSummary((prev) => { setWorkSummary((prev) => {
@ -500,6 +514,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
// Otimização local: aplicar duração retornada no total e limpar sessão ativa // Otimização local: aplicar duração retornada no total e limpar sessão ativa
const delta = typeof (result as { durationMs?: unknown })?.durationMs === "number" ? (result as { durationMs?: number }).durationMs! : 0 const delta = typeof (result as { durationMs?: unknown })?.durationMs === "number" ? (result as { durationMs?: number }).durationMs! : 0
localStartAtRef.current = 0 localStartAtRef.current = 0
localStartOriginRef.current = "unknown"
setWorkSummary((prev) => { setWorkSummary((prev) => {
if (!prev) return prev if (!prev) return prev
const workType = prev.activeSession?.workType ?? "INTERNAL" const workType = prev.activeSession?.workType ?? "INTERNAL"

View file

@ -0,0 +1,40 @@
export type SessionStartOrigin = "unknown" | "remote" | "fresh-local" | "already-running-fallback"
interface ReconcileArgs {
remoteStart: number
localStart: number
origin: SessionStartOrigin
}
interface ReconcileResult {
localStart: number
origin: SessionStartOrigin
}
/**
* Reconcilia o início da sessão local com o timestamp vindo do servidor.
* Mantém a proteção contra timestamps remotos defasados (que geravam tempo inflado),
* mas permite substituir o fallback local quando sabemos que existia uma sessão em andamento.
*/
export function reconcileLocalSessionStart({ remoteStart, localStart, origin }: ReconcileArgs): ReconcileResult {
const sanitizedRemote = Number(remoteStart) || 0
const sanitizedLocal = Number(localStart) || 0
if (sanitizedRemote <= 0) {
return { localStart: sanitizedLocal, origin }
}
if (sanitizedLocal <= 0) {
return { localStart: sanitizedRemote, origin: "remote" }
}
if (sanitizedRemote >= sanitizedLocal) {
return { localStart: sanitizedRemote, origin: "remote" }
}
if (origin === "already-running-fallback") {
return { localStart: sanitizedRemote, origin: "remote" }
}
return { localStart: sanitizedLocal, origin }
}

View file

@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest"
import { reconcileLocalSessionStart } from "@/components/tickets/ticket-timer.utils"
describe("reconcileLocalSessionStart", () => {
it("usa o timestamp remoto quando não há marcador local", () => {
const result = reconcileLocalSessionStart({ remoteStart: 5000, localStart: 0, origin: "unknown" })
expect(result).toEqual({ localStart: 5000, origin: "remote" })
})
it("mantém o fallback local quando o remoto é menor e a sessão acabou de começar", () => {
const result = reconcileLocalSessionStart({
remoteStart: 1_000,
localStart: 2_000,
origin: "fresh-local",
})
expect(result).toEqual({ localStart: 2_000, origin: "fresh-local" })
})
it("substitui o fallback local quando já havia sessão em andamento", () => {
const result = reconcileLocalSessionStart({
remoteStart: 1_000,
localStart: 2_000,
origin: "already-running-fallback",
})
expect(result).toEqual({ localStart: 1_000, origin: "remote" })
})
it("sincroniza com o servidor quando o remoto é mais recente", () => {
const result = reconcileLocalSessionStart({
remoteStart: 4_000,
localStart: 2_000,
origin: "fresh-local",
})
expect(result).toEqual({ localStart: 4_000, origin: "remote" })
})
})