diff --git a/src/components/tickets/ticket-summary-header.tsx b/src/components/tickets/ticket-summary-header.tsx index 0eabe08..cf541e5 100644 --- a/src/components/tickets/ticket-summary-header.tsx +++ b/src/components/tickets/ticket-summary-header.tsx @@ -31,6 +31,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { reconcileLocalSessionStart, type SessionStartOrigin } from "./ticket-timer.utils" interface TicketHeaderProps { 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 // timestamps defasados vindos da rede. Escolhemos o MAIOR entre (remoto, local). const localStartAtRef = useRef(0) + const localStartOriginRef = useRef("unknown") useEffect(() => { if (!workSummary?.activeSession) return @@ -403,12 +405,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { useEffect(() => { if (!workSummary?.activeSession) { localStartAtRef.current = 0 + localStartOriginRef.current = "unknown" return } - const remoteStart = Number(workSummary.activeSession.startedAt) || 0 - if (remoteStart > localStartAtRef.current) { - localStartAtRef.current = remoteStart - } + const { localStart, origin } = reconcileLocalSessionStart({ + remoteStart: Number(workSummary.activeSession.startedAt) || 0, + localStart: localStartAtRef.current, + origin: localStartOriginRef.current, + }) + localStartAtRef.current = localStart + localStartOriginRef.current = origin }, [workSummary?.activeSession?.id, workSummary?.activeSession?.startedAt]) useEffect(() => { @@ -446,13 +452,21 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { toast.loading("Iniciando atendimento...", { id: "work" }) try { 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" }) } else { toast.success("Atendimento iniciado", { id: "work" }) } // Otimização local: garantir startedAt correto imediatamente 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 const sessionId = (result as { sessionId?: unknown })?.sessionId as Id<"ticketWorkSessions"> | undefined 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 const delta = typeof (result as { durationMs?: unknown })?.durationMs === "number" ? (result as { durationMs?: number }).durationMs! : 0 localStartAtRef.current = 0 + localStartOriginRef.current = "unknown" setWorkSummary((prev) => { if (!prev) return prev const workType = prev.activeSession?.workType ?? "INTERNAL" diff --git a/src/components/tickets/ticket-timer.utils.ts b/src/components/tickets/ticket-timer.utils.ts new file mode 100644 index 0000000..545e290 --- /dev/null +++ b/src/components/tickets/ticket-timer.utils.ts @@ -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 já 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 } +} diff --git a/tests/ticket-timer.test.ts b/tests/ticket-timer.test.ts new file mode 100644 index 0000000..5dbe977 --- /dev/null +++ b/tests/ticket-timer.test.ts @@ -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" }) + }) +})