fix: reconcile ticket timer with server start
This commit is contained in:
parent
1df7e13c8f
commit
3b5676ed35
3 changed files with 96 additions and 5 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
40
src/components/tickets/ticket-timer.utils.ts
Normal file
40
src/components/tickets/ticket-timer.utils.ts
Normal 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 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 }
|
||||||
|
}
|
||||||
36
tests/ticket-timer.test.ts
Normal file
36
tests/ticket-timer.test.ts
Normal 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" })
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue