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

@ -461,6 +461,7 @@ export const list = query({
subcategorySummary = { id: subcategory._id, name: subcategory.name };
}
}
const serverNow = Date.now()
return {
id: t._id,
reference: t.reference,
@ -497,6 +498,7 @@ export const list = query({
totalWorkedMs: t.totalWorkedMs ?? 0,
internalWorkedMs: t.internalWorkedMs ?? 0,
externalWorkedMs: t.externalWorkedMs ?? 0,
serverNow,
activeSession: activeSession
? {
id: activeSession._id,
@ -546,6 +548,7 @@ export const getById = query({
visibleComments.map((comment) => `${comment.createdAt}:${comment.authorId}`)
)
const visibleCommentTimestamps = new Set(visibleComments.map((comment) => comment.createdAt))
const serverNow = Date.now()
let timelineRecords = await ctx.db
.query("ticketEvents")
@ -678,6 +681,7 @@ export const getById = query({
totalWorkedMs: t.totalWorkedMs ?? 0,
internalWorkedMs: t.internalWorkedMs ?? 0,
externalWorkedMs: t.externalWorkedMs ?? 0,
serverNow,
activeSession: activeSession
? {
id: activeSession._id,
@ -1255,11 +1259,13 @@ export const workSummary = query({
await requireStaff(ctx, viewerId, ticket.tenantId)
const activeSession = ticket.activeSessionId ? await ctx.db.get(ticket.activeSessionId) : null
const serverNow = Date.now()
return {
ticketId,
totalWorkedMs: ticket.totalWorkedMs ?? 0,
internalWorkedMs: ticket.internalWorkedMs ?? 0,
externalWorkedMs: ticket.externalWorkedMs ?? 0,
serverNow,
activeSession: activeSession
? {
id: activeSession._id,
@ -1303,16 +1309,22 @@ export const startWork = mutation({
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
const isAdmin = viewer.role === "ADMIN"
const currentAssigneeId = ticketDoc.assigneeId ?? null
const now = Date.now()
if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) {
throw new ConvexError("Somente o responsável atual pode iniciar este chamado")
}
if (ticketDoc.activeSessionId) {
return { status: "already_started", sessionId: ticketDoc.activeSessionId }
const session = await ctx.db.get(ticketDoc.activeSessionId)
return {
status: "already_started",
sessionId: ticketDoc.activeSessionId,
startedAt: session?.startedAt ?? now,
serverNow: now,
}
}
const now = Date.now()
let assigneePatched = false
if (!currentAssigneeId) {
@ -1356,7 +1368,7 @@ export const startWork = mutation({
createdAt: now,
})
return { status: "started", sessionId, startedAt: now }
return { status: "started", sessionId, startedAt: now, serverNow: now }
},
})
@ -1439,6 +1451,7 @@ export const pauseWork = mutation({
durationMs,
pauseReason: reason,
pauseNote: note ?? "",
serverNow: now,
}
},
})

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,
}
})

View file

@ -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
}

View file

@ -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
}

View file

@ -71,6 +71,7 @@ const serverTicketSchema = z.object({
totalWorkedMs: z.number(),
internalWorkedMs: z.number().optional(),
externalWorkedMs: z.number().optional(),
serverNow: z.number().optional(),
activeSession: z
.object({
id: z.string(),
@ -143,6 +144,7 @@ export function mapTicketFromServer(input: unknown) {
totalWorkedMs: s.workSummary.totalWorkedMs,
internalWorkedMs: s.workSummary.internalWorkedMs ?? 0,
externalWorkedMs: s.workSummary.externalWorkedMs ?? 0,
serverNow: s.workSummary.serverNow,
activeSession: s.workSummary.activeSession
? {
...s.workSummary.activeSession,
@ -201,6 +203,9 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
workSummary: s.workSummary
? {
totalWorkedMs: s.workSummary.totalWorkedMs,
internalWorkedMs: s.workSummary.internalWorkedMs ?? 0,
externalWorkedMs: s.workSummary.externalWorkedMs ?? 0,
serverNow: s.workSummary.serverNow,
activeSession: s.workSummary.activeSession
? {
...s.workSummary.activeSession,

View file

@ -135,6 +135,7 @@ export const ticketSchema = z.object({
totalWorkedMs: z.number(),
internalWorkedMs: z.number().optional().default(0),
externalWorkedMs: z.number().optional().default(0),
serverNow: z.number().optional(),
activeSession: z
.object({
id: z.string(),

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"
import { reconcileLocalSessionStart } from "@/components/tickets/ticket-timer.utils"
import { deriveServerOffset, reconcileLocalSessionStart, toServerTimestamp } from "@/components/tickets/ticket-timer.utils"
describe("reconcileLocalSessionStart", () => {
it("usa o timestamp remoto quando não há marcador local", () => {
@ -34,3 +34,26 @@ describe("reconcileLocalSessionStart", () => {
expect(result).toEqual({ localStart: 4_000, origin: "remote" })
})
})
describe("deriveServerOffset", () => {
it("calcula deslocamento simples entre relógios", () => {
const offset = deriveServerOffset({ currentOffset: 0, localNow: 1_700_000, serverNow: 1_699_000 })
expect(offset).toBe(1_000)
})
it("ignora serverNow inválido", () => {
const offset = deriveServerOffset({ currentOffset: 500, localNow: 2_000_000, serverNow: Number.NaN })
expect(offset).toBe(500)
})
it("aceita saltos grandes quando necessário", () => {
const offset = deriveServerOffset({ currentOffset: 500, localNow: 2_000_000, serverNow: 1_000_000 })
expect(offset).toBe(1_000_000)
})
})
describe("toServerTimestamp", () => {
it("aplica offset corretamente", () => {
expect(toServerTimestamp(1_000_000, 1_500)).toBe(998_500)
})
})