fix: align ticket timers to server clock

This commit is contained in:
Esdras Renan 2025-10-19 20:27:11 -03:00
parent 3b5676ed35
commit 090ebb9607
7 changed files with 162 additions and 17 deletions

View file

@ -31,7 +31,12 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { reconcileLocalSessionStart, type SessionStartOrigin } from "./ticket-timer.utils"
import {
deriveServerOffset,
reconcileLocalSessionStart,
toServerTimestamp,
type SessionStartOrigin,
} from "./ticket-timer.utils"
interface TicketHeaderProps {
ticket: TicketWithDetails
@ -42,6 +47,7 @@ type WorkSummarySnapshot = {
totalWorkedMs: number
internalWorkedMs: number
externalWorkedMs: number
serverNow?: number | null
activeSession: {
id: Id<"ticketWorkSessions">
agentId: Id<"users">
@ -122,6 +128,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
totalWorkedMs: number
internalWorkedMs?: number
externalWorkedMs?: number
serverNow?: number
activeSession: {
id: Id<"ticketWorkSessions">
agentId: Id<"users">
@ -341,6 +348,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
totalWorkedMs: ticket.workSummary.totalWorkedMs ?? 0,
internalWorkedMs: ticket.workSummary.internalWorkedMs ?? 0,
externalWorkedMs: ticket.workSummary.externalWorkedMs ?? 0,
serverNow: typeof ticket.workSummary.serverNow === "number" ? ticket.workSummary.serverNow : null,
activeSession: ticketActiveSession
? {
id: ticketActiveSession.id as Id<"ticketWorkSessions">,
@ -359,10 +367,31 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
])
const [workSummary, setWorkSummary] = useState<WorkSummarySnapshot | null>(initialWorkSummary)
const serverOffsetRef = useRef<number>(0)
const calibrateServerOffset = useCallback(
(serverNow?: number | Date | null) => {
if (serverNow === undefined || serverNow === null) return
const serverMs =
serverNow instanceof Date ? serverNow.getTime() : Number(serverNow)
if (!Number.isFinite(serverMs)) return
serverOffsetRef.current = deriveServerOffset({
currentOffset: serverOffsetRef.current,
localNow: Date.now(),
serverNow: serverMs,
})
},
[]
)
const getServerNow = useCallback(() => toServerTimestamp(Date.now(), serverOffsetRef.current), [])
useEffect(() => {
if (initialWorkSummary?.serverNow) {
calibrateServerOffset(initialWorkSummary.serverNow)
}
setWorkSummary(initialWorkSummary)
}, [initialWorkSummary])
}, [initialWorkSummary, calibrateServerOffset])
useEffect(() => {
if (workSummaryRemote === undefined) return
@ -370,11 +399,15 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setWorkSummary(null)
return
}
if (typeof workSummaryRemote.serverNow === "number") {
calibrateServerOffset(workSummaryRemote.serverNow)
}
setWorkSummary({
ticketId: workSummaryRemote.ticketId,
totalWorkedMs: workSummaryRemote.totalWorkedMs ?? 0,
internalWorkedMs: workSummaryRemote.internalWorkedMs ?? 0,
externalWorkedMs: workSummaryRemote.externalWorkedMs ?? 0,
serverNow: typeof workSummaryRemote.serverNow === "number" ? workSummaryRemote.serverNow : null,
activeSession: workSummaryRemote.activeSession
? {
id: workSummaryRemote.activeSession.id,
@ -384,7 +417,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}
: null,
})
}, [workSummaryRemote])
}, [workSummaryRemote, calibrateServerOffset])
const isPlaying = Boolean(workSummary?.activeSession)
const [now, setNow] = useState(() => Date.now())
@ -429,7 +462,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
? (() => {
const remoteStart = Number(workSummary.activeSession.startedAt) || 0
const effectiveStart = Math.max(remoteStart, localStartAtRef.current || 0)
return Math.max(0, now - effectiveStart)
const alignedNow = toServerTimestamp(now, serverOffsetRef.current)
return Math.max(0, alignedNow - effectiveStart)
})()
: 0
const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0
@ -451,16 +485,29 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
toast.dismiss("work")
toast.loading("Iniciando atendimento...", { id: "work" })
try {
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users">, workType })
const status = (result as { status?: string } | null)?.status ?? "started"
const result = await startWork({
ticketId: ticket.id as Id<"tickets">,
actorId: convexUserId as Id<"users">,
workType,
})
const resultMeta = result as {
status?: string
startedAt?: number
sessionId?: Id<"ticketWorkSessions">
serverNow?: number
}
const status = resultMeta?.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") {
calibrateServerOffset(resultMeta?.serverNow ?? null)
const startedAtMsRaw = resultMeta?.startedAt
const startedAtMs =
typeof startedAtMsRaw === "number" && Number.isFinite(startedAtMsRaw) ? startedAtMsRaw : getServerNow()
if (typeof startedAtMsRaw === "number") {
localStartOriginRef.current = "remote"
} else if (status === "already_started") {
localStartOriginRef.current = "already-running-fallback"
@ -468,17 +515,19 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
localStartOriginRef.current = "fresh-local"
}
localStartAtRef.current = startedAtMs
const sessionId = (result as { sessionId?: unknown })?.sessionId as Id<"ticketWorkSessions"> | undefined
const sessionId = resultMeta?.sessionId
setWorkSummary((prev) => {
const base: WorkSummarySnapshot = prev ?? {
ticketId: ticket.id as Id<"tickets">,
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
serverNow: null,
activeSession: null,
}
return {
...base,
serverNow: typeof resultMeta?.serverNow === "number" ? resultMeta.serverNow : getServerNow(),
activeSession: {
id: (sessionId as Id<"ticketWorkSessions">) ?? (base.activeSession?.id as Id<"ticketWorkSessions">),
agentId: convexUserId as Id<"users">,
@ -505,14 +554,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
reason: pauseReason,
note: pauseNote.trim() ? pauseNote.trim() : undefined,
})
if (result?.status === "already_paused") {
const resultMeta = result as { status?: string; durationMs?: number; serverNow?: number }
if (resultMeta?.status === "already_paused") {
toast.info("O atendimento já estava pausado", { id: "work" })
} else {
toast.success("Atendimento pausado", { id: "work" })
}
setPauseDialogOpen(false)
// 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
calibrateServerOffset(resultMeta?.serverNow ?? null)
const delta = typeof resultMeta?.durationMs === "number" ? resultMeta.durationMs : 0
localStartAtRef.current = 0
localStartOriginRef.current = "unknown"
setWorkSummary((prev) => {
@ -523,6 +574,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
totalWorkedMs: prev.totalWorkedMs + delta,
internalWorkedMs: prev.internalWorkedMs + (workType === "INTERNAL" ? delta : 0),
externalWorkedMs: prev.externalWorkedMs + (workType === "EXTERNAL" ? delta : 0),
serverNow: typeof resultMeta?.serverNow === "number" ? resultMeta.serverNow : getServerNow(),
activeSession: null,
}
})