From 090ebb96072ac1f8b2142940b3e7e3bd6255738e Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Sun, 19 Oct 2025 20:27:11 -0300 Subject: [PATCH] fix: align ticket timers to server clock --- convex/tickets.ts | 19 ++++- .../tickets/ticket-summary-header.tsx | 74 ++++++++++++++++--- src/components/tickets/ticket-timer.utils.ts | 35 +++++++++ src/components/tickets/tickets-table.tsx | 20 ++++- src/lib/mappers/ticket.ts | 5 ++ src/lib/schemas/ticket.ts | 1 + tests/ticket-timer.test.ts | 25 ++++++- 7 files changed, 162 insertions(+), 17 deletions(-) diff --git a/convex/tickets.ts b/convex/tickets.ts index 784da7e..e9a1059 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -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, } }, }) diff --git a/src/components/tickets/ticket-summary-header.tsx b/src/components/tickets/ticket-summary-header.tsx index cf541e5..efc7fde 100644 --- a/src/components/tickets/ticket-summary-header.tsx +++ b/src/components/tickets/ticket-summary-header.tsx @@ -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(initialWorkSummary) + const serverOffsetRef = useRef(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, } }) diff --git a/src/components/tickets/ticket-timer.utils.ts b/src/components/tickets/ticket-timer.utils.ts index 545e290..39b8ab0 100644 --- a/src/components/tickets/ticket-timer.utils.ts +++ b/src/components/tickets/ticket-timer.utils.ts @@ -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 +} diff --git a/src/components/tickets/tickets-table.tsx b/src/components/tickets/tickets-table.tsx index a66df45..a1e5b56 100644 --- a/src/components/tickets/tickets-table.tsx +++ b/src/components/tickets/tickets-table.tsx @@ -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 = { 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(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 } diff --git a/src/lib/mappers/ticket.ts b/src/lib/mappers/ticket.ts index 6f4b5fd..754d153 100644 --- a/src/lib/mappers/ticket.ts +++ b/src/lib/mappers/ticket.ts @@ -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, diff --git a/src/lib/schemas/ticket.ts b/src/lib/schemas/ticket.ts index 7941fe8..640876f 100644 --- a/src/lib/schemas/ticket.ts +++ b/src/lib/schemas/ticket.ts @@ -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(), diff --git a/tests/ticket-timer.test.ts b/tests/ticket-timer.test.ts index 5dbe977..c802334 100644 --- a/tests/ticket-timer.test.ts +++ b/tests/ticket-timer.test.ts @@ -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) + }) +})