fix: align ticket timers to server clock
This commit is contained in:
parent
3b5676ed35
commit
090ebb9607
7 changed files with 162 additions and 17 deletions
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -38,3 +38,38 @@ export function reconcileLocalSessionStart({ remoteStart, localStart, origin }:
|
|||
|
||||
return { localStart: sanitizedLocal, origin }
|
||||
}
|
||||
|
||||
interface OffsetArgs {
|
||||
currentOffset: number
|
||||
localNow: number
|
||||
serverNow?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula o deslocamento entre o relógio do navegador e o do servidor.
|
||||
* Usamos o menor número de suposições possível: basta termos uma leitura de serverNow
|
||||
* para alinhar o relógio local.
|
||||
*/
|
||||
export function deriveServerOffset({ currentOffset, localNow, serverNow }: OffsetArgs): number {
|
||||
if (typeof serverNow !== "number" || Number.isNaN(serverNow)) {
|
||||
return currentOffset
|
||||
}
|
||||
const nextOffset = localNow - serverNow
|
||||
if (!Number.isFinite(nextOffset)) {
|
||||
return currentOffset
|
||||
}
|
||||
if (!Number.isFinite(currentOffset) || currentOffset === 0) {
|
||||
return nextOffset
|
||||
}
|
||||
const MAX_REASONABLE_JUMP = 5 * 60 * 1000 // 5 minutos
|
||||
if (Math.abs(nextOffset - currentOffset) > MAX_REASONABLE_JUMP) {
|
||||
return nextOffset
|
||||
}
|
||||
return nextOffset
|
||||
}
|
||||
|
||||
export function toServerTimestamp(localNow: number, offset: number): number {
|
||||
if (!Number.isFinite(localNow)) return localNow
|
||||
if (!Number.isFinite(offset)) return localNow
|
||||
return localNow - offset
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { format, formatDistanceToNowStrict } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
} from "@/components/ui/table"
|
||||
import { PrioritySelect } from "@/components/tickets/priority-select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { deriveServerOffset, toServerTimestamp } from "@/components/tickets/ticket-timer.utils"
|
||||
|
||||
const channelLabel: Record<TicketChannel, string> = {
|
||||
EMAIL: "E-mail",
|
||||
|
|
@ -112,6 +113,7 @@ export type TicketsTableProps = {
|
|||
export function TicketsTable({ tickets }: TicketsTableProps) {
|
||||
const safeTickets = tickets ?? []
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const serverOffsetRef = useRef<number>(0)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -121,11 +123,25 @@ export function TicketsTable({ tickets }: TicketsTableProps) {
|
|||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const candidates = (tickets ?? [])
|
||||
.map((ticket) => (typeof ticket.workSummary?.serverNow === "number" ? ticket.workSummary.serverNow : null))
|
||||
.filter((value): value is number => value !== null)
|
||||
if (candidates.length === 0) return
|
||||
const latestServerNow = candidates[candidates.length - 1]
|
||||
serverOffsetRef.current = deriveServerOffset({
|
||||
currentOffset: serverOffsetRef.current,
|
||||
localNow: Date.now(),
|
||||
serverNow: latestServerNow,
|
||||
})
|
||||
}, [tickets])
|
||||
|
||||
const getWorkedMs = (ticket: Ticket) => {
|
||||
const base = ticket.workSummary?.totalWorkedMs ?? 0
|
||||
const activeStart = ticket.workSummary?.activeSession?.startedAt
|
||||
if (activeStart instanceof Date) {
|
||||
return base + Math.max(0, now - activeStart.getTime())
|
||||
const alignedNow = toServerTimestamp(now, serverOffsetRef.current)
|
||||
return base + Math.max(0, alignedNow - activeStart.getTime())
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue