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
|
|
@ -461,6 +461,7 @@ export const list = query({
|
||||||
subcategorySummary = { id: subcategory._id, name: subcategory.name };
|
subcategorySummary = { id: subcategory._id, name: subcategory.name };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const serverNow = Date.now()
|
||||||
return {
|
return {
|
||||||
id: t._id,
|
id: t._id,
|
||||||
reference: t.reference,
|
reference: t.reference,
|
||||||
|
|
@ -497,6 +498,7 @@ export const list = query({
|
||||||
totalWorkedMs: t.totalWorkedMs ?? 0,
|
totalWorkedMs: t.totalWorkedMs ?? 0,
|
||||||
internalWorkedMs: t.internalWorkedMs ?? 0,
|
internalWorkedMs: t.internalWorkedMs ?? 0,
|
||||||
externalWorkedMs: t.externalWorkedMs ?? 0,
|
externalWorkedMs: t.externalWorkedMs ?? 0,
|
||||||
|
serverNow,
|
||||||
activeSession: activeSession
|
activeSession: activeSession
|
||||||
? {
|
? {
|
||||||
id: activeSession._id,
|
id: activeSession._id,
|
||||||
|
|
@ -546,6 +548,7 @@ export const getById = query({
|
||||||
visibleComments.map((comment) => `${comment.createdAt}:${comment.authorId}`)
|
visibleComments.map((comment) => `${comment.createdAt}:${comment.authorId}`)
|
||||||
)
|
)
|
||||||
const visibleCommentTimestamps = new Set(visibleComments.map((comment) => comment.createdAt))
|
const visibleCommentTimestamps = new Set(visibleComments.map((comment) => comment.createdAt))
|
||||||
|
const serverNow = Date.now()
|
||||||
|
|
||||||
let timelineRecords = await ctx.db
|
let timelineRecords = await ctx.db
|
||||||
.query("ticketEvents")
|
.query("ticketEvents")
|
||||||
|
|
@ -678,6 +681,7 @@ export const getById = query({
|
||||||
totalWorkedMs: t.totalWorkedMs ?? 0,
|
totalWorkedMs: t.totalWorkedMs ?? 0,
|
||||||
internalWorkedMs: t.internalWorkedMs ?? 0,
|
internalWorkedMs: t.internalWorkedMs ?? 0,
|
||||||
externalWorkedMs: t.externalWorkedMs ?? 0,
|
externalWorkedMs: t.externalWorkedMs ?? 0,
|
||||||
|
serverNow,
|
||||||
activeSession: activeSession
|
activeSession: activeSession
|
||||||
? {
|
? {
|
||||||
id: activeSession._id,
|
id: activeSession._id,
|
||||||
|
|
@ -1255,11 +1259,13 @@ export const workSummary = query({
|
||||||
await requireStaff(ctx, viewerId, ticket.tenantId)
|
await requireStaff(ctx, viewerId, ticket.tenantId)
|
||||||
|
|
||||||
const activeSession = ticket.activeSessionId ? await ctx.db.get(ticket.activeSessionId) : null
|
const activeSession = ticket.activeSessionId ? await ctx.db.get(ticket.activeSessionId) : null
|
||||||
|
const serverNow = Date.now()
|
||||||
return {
|
return {
|
||||||
ticketId,
|
ticketId,
|
||||||
totalWorkedMs: ticket.totalWorkedMs ?? 0,
|
totalWorkedMs: ticket.totalWorkedMs ?? 0,
|
||||||
internalWorkedMs: ticket.internalWorkedMs ?? 0,
|
internalWorkedMs: ticket.internalWorkedMs ?? 0,
|
||||||
externalWorkedMs: ticket.externalWorkedMs ?? 0,
|
externalWorkedMs: ticket.externalWorkedMs ?? 0,
|
||||||
|
serverNow,
|
||||||
activeSession: activeSession
|
activeSession: activeSession
|
||||||
? {
|
? {
|
||||||
id: activeSession._id,
|
id: activeSession._id,
|
||||||
|
|
@ -1303,16 +1309,22 @@ export const startWork = mutation({
|
||||||
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
||||||
const isAdmin = viewer.role === "ADMIN"
|
const isAdmin = viewer.role === "ADMIN"
|
||||||
const currentAssigneeId = ticketDoc.assigneeId ?? null
|
const currentAssigneeId = ticketDoc.assigneeId ?? null
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) {
|
if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) {
|
||||||
throw new ConvexError("Somente o responsável atual pode iniciar este chamado")
|
throw new ConvexError("Somente o responsável atual pode iniciar este chamado")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ticketDoc.activeSessionId) {
|
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
|
let assigneePatched = false
|
||||||
|
|
||||||
if (!currentAssigneeId) {
|
if (!currentAssigneeId) {
|
||||||
|
|
@ -1356,7 +1368,7 @@ export const startWork = mutation({
|
||||||
createdAt: now,
|
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,
|
durationMs,
|
||||||
pauseReason: reason,
|
pauseReason: reason,
|
||||||
pauseNote: note ?? "",
|
pauseNote: note ?? "",
|
||||||
|
serverNow: now,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,12 @@ 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"
|
import {
|
||||||
|
deriveServerOffset,
|
||||||
|
reconcileLocalSessionStart,
|
||||||
|
toServerTimestamp,
|
||||||
|
type SessionStartOrigin,
|
||||||
|
} from "./ticket-timer.utils"
|
||||||
|
|
||||||
interface TicketHeaderProps {
|
interface TicketHeaderProps {
|
||||||
ticket: TicketWithDetails
|
ticket: TicketWithDetails
|
||||||
|
|
@ -42,6 +47,7 @@ type WorkSummarySnapshot = {
|
||||||
totalWorkedMs: number
|
totalWorkedMs: number
|
||||||
internalWorkedMs: number
|
internalWorkedMs: number
|
||||||
externalWorkedMs: number
|
externalWorkedMs: number
|
||||||
|
serverNow?: number | null
|
||||||
activeSession: {
|
activeSession: {
|
||||||
id: Id<"ticketWorkSessions">
|
id: Id<"ticketWorkSessions">
|
||||||
agentId: Id<"users">
|
agentId: Id<"users">
|
||||||
|
|
@ -122,6 +128,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
totalWorkedMs: number
|
totalWorkedMs: number
|
||||||
internalWorkedMs?: number
|
internalWorkedMs?: number
|
||||||
externalWorkedMs?: number
|
externalWorkedMs?: number
|
||||||
|
serverNow?: number
|
||||||
activeSession: {
|
activeSession: {
|
||||||
id: Id<"ticketWorkSessions">
|
id: Id<"ticketWorkSessions">
|
||||||
agentId: Id<"users">
|
agentId: Id<"users">
|
||||||
|
|
@ -341,6 +348,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
totalWorkedMs: ticket.workSummary.totalWorkedMs ?? 0,
|
totalWorkedMs: ticket.workSummary.totalWorkedMs ?? 0,
|
||||||
internalWorkedMs: ticket.workSummary.internalWorkedMs ?? 0,
|
internalWorkedMs: ticket.workSummary.internalWorkedMs ?? 0,
|
||||||
externalWorkedMs: ticket.workSummary.externalWorkedMs ?? 0,
|
externalWorkedMs: ticket.workSummary.externalWorkedMs ?? 0,
|
||||||
|
serverNow: typeof ticket.workSummary.serverNow === "number" ? ticket.workSummary.serverNow : null,
|
||||||
activeSession: ticketActiveSession
|
activeSession: ticketActiveSession
|
||||||
? {
|
? {
|
||||||
id: ticketActiveSession.id as Id<"ticketWorkSessions">,
|
id: ticketActiveSession.id as Id<"ticketWorkSessions">,
|
||||||
|
|
@ -359,10 +367,31 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
])
|
])
|
||||||
|
|
||||||
const [workSummary, setWorkSummary] = useState<WorkSummarySnapshot | null>(initialWorkSummary)
|
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(() => {
|
useEffect(() => {
|
||||||
|
if (initialWorkSummary?.serverNow) {
|
||||||
|
calibrateServerOffset(initialWorkSummary.serverNow)
|
||||||
|
}
|
||||||
setWorkSummary(initialWorkSummary)
|
setWorkSummary(initialWorkSummary)
|
||||||
}, [initialWorkSummary])
|
}, [initialWorkSummary, calibrateServerOffset])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (workSummaryRemote === undefined) return
|
if (workSummaryRemote === undefined) return
|
||||||
|
|
@ -370,11 +399,15 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
setWorkSummary(null)
|
setWorkSummary(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (typeof workSummaryRemote.serverNow === "number") {
|
||||||
|
calibrateServerOffset(workSummaryRemote.serverNow)
|
||||||
|
}
|
||||||
setWorkSummary({
|
setWorkSummary({
|
||||||
ticketId: workSummaryRemote.ticketId,
|
ticketId: workSummaryRemote.ticketId,
|
||||||
totalWorkedMs: workSummaryRemote.totalWorkedMs ?? 0,
|
totalWorkedMs: workSummaryRemote.totalWorkedMs ?? 0,
|
||||||
internalWorkedMs: workSummaryRemote.internalWorkedMs ?? 0,
|
internalWorkedMs: workSummaryRemote.internalWorkedMs ?? 0,
|
||||||
externalWorkedMs: workSummaryRemote.externalWorkedMs ?? 0,
|
externalWorkedMs: workSummaryRemote.externalWorkedMs ?? 0,
|
||||||
|
serverNow: typeof workSummaryRemote.serverNow === "number" ? workSummaryRemote.serverNow : null,
|
||||||
activeSession: workSummaryRemote.activeSession
|
activeSession: workSummaryRemote.activeSession
|
||||||
? {
|
? {
|
||||||
id: workSummaryRemote.activeSession.id,
|
id: workSummaryRemote.activeSession.id,
|
||||||
|
|
@ -384,7 +417,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
})
|
})
|
||||||
}, [workSummaryRemote])
|
}, [workSummaryRemote, calibrateServerOffset])
|
||||||
|
|
||||||
const isPlaying = Boolean(workSummary?.activeSession)
|
const isPlaying = Boolean(workSummary?.activeSession)
|
||||||
const [now, setNow] = useState(() => Date.now())
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
@ -429,7 +462,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
? (() => {
|
? (() => {
|
||||||
const remoteStart = Number(workSummary.activeSession.startedAt) || 0
|
const remoteStart = Number(workSummary.activeSession.startedAt) || 0
|
||||||
const effectiveStart = Math.max(remoteStart, localStartAtRef.current || 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
|
: 0
|
||||||
const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0
|
const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0
|
||||||
|
|
@ -451,16 +485,29 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
toast.dismiss("work")
|
toast.dismiss("work")
|
||||||
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({
|
||||||
const status = (result as { status?: string } | null)?.status ?? "started"
|
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") {
|
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()
|
calibrateServerOffset(resultMeta?.serverNow ?? null)
|
||||||
if (typeof result?.startedAt === "number") {
|
const startedAtMsRaw = resultMeta?.startedAt
|
||||||
|
const startedAtMs =
|
||||||
|
typeof startedAtMsRaw === "number" && Number.isFinite(startedAtMsRaw) ? startedAtMsRaw : getServerNow()
|
||||||
|
if (typeof startedAtMsRaw === "number") {
|
||||||
localStartOriginRef.current = "remote"
|
localStartOriginRef.current = "remote"
|
||||||
} else if (status === "already_started") {
|
} else if (status === "already_started") {
|
||||||
localStartOriginRef.current = "already-running-fallback"
|
localStartOriginRef.current = "already-running-fallback"
|
||||||
|
|
@ -468,17 +515,19 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
localStartOriginRef.current = "fresh-local"
|
localStartOriginRef.current = "fresh-local"
|
||||||
}
|
}
|
||||||
localStartAtRef.current = startedAtMs
|
localStartAtRef.current = startedAtMs
|
||||||
const sessionId = (result as { sessionId?: unknown })?.sessionId as Id<"ticketWorkSessions"> | undefined
|
const sessionId = resultMeta?.sessionId
|
||||||
setWorkSummary((prev) => {
|
setWorkSummary((prev) => {
|
||||||
const base: WorkSummarySnapshot = prev ?? {
|
const base: WorkSummarySnapshot = prev ?? {
|
||||||
ticketId: ticket.id as Id<"tickets">,
|
ticketId: ticket.id as Id<"tickets">,
|
||||||
totalWorkedMs: 0,
|
totalWorkedMs: 0,
|
||||||
internalWorkedMs: 0,
|
internalWorkedMs: 0,
|
||||||
externalWorkedMs: 0,
|
externalWorkedMs: 0,
|
||||||
|
serverNow: null,
|
||||||
activeSession: null,
|
activeSession: null,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
|
serverNow: typeof resultMeta?.serverNow === "number" ? resultMeta.serverNow : getServerNow(),
|
||||||
activeSession: {
|
activeSession: {
|
||||||
id: (sessionId as Id<"ticketWorkSessions">) ?? (base.activeSession?.id as Id<"ticketWorkSessions">),
|
id: (sessionId as Id<"ticketWorkSessions">) ?? (base.activeSession?.id as Id<"ticketWorkSessions">),
|
||||||
agentId: convexUserId as Id<"users">,
|
agentId: convexUserId as Id<"users">,
|
||||||
|
|
@ -505,14 +554,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
reason: pauseReason,
|
reason: pauseReason,
|
||||||
note: pauseNote.trim() ? pauseNote.trim() : undefined,
|
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" })
|
toast.info("O atendimento já estava pausado", { id: "work" })
|
||||||
} else {
|
} else {
|
||||||
toast.success("Atendimento pausado", { id: "work" })
|
toast.success("Atendimento pausado", { id: "work" })
|
||||||
}
|
}
|
||||||
setPauseDialogOpen(false)
|
setPauseDialogOpen(false)
|
||||||
// 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
|
calibrateServerOffset(resultMeta?.serverNow ?? null)
|
||||||
|
const delta = typeof resultMeta?.durationMs === "number" ? resultMeta.durationMs : 0
|
||||||
localStartAtRef.current = 0
|
localStartAtRef.current = 0
|
||||||
localStartOriginRef.current = "unknown"
|
localStartOriginRef.current = "unknown"
|
||||||
setWorkSummary((prev) => {
|
setWorkSummary((prev) => {
|
||||||
|
|
@ -523,6 +574,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
totalWorkedMs: prev.totalWorkedMs + delta,
|
totalWorkedMs: prev.totalWorkedMs + delta,
|
||||||
internalWorkedMs: prev.internalWorkedMs + (workType === "INTERNAL" ? delta : 0),
|
internalWorkedMs: prev.internalWorkedMs + (workType === "INTERNAL" ? delta : 0),
|
||||||
externalWorkedMs: prev.externalWorkedMs + (workType === "EXTERNAL" ? delta : 0),
|
externalWorkedMs: prev.externalWorkedMs + (workType === "EXTERNAL" ? delta : 0),
|
||||||
|
serverNow: typeof resultMeta?.serverNow === "number" ? resultMeta.serverNow : getServerNow(),
|
||||||
activeSession: null,
|
activeSession: null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -38,3 +38,38 @@ export function reconcileLocalSessionStart({ remoteStart, localStart, origin }:
|
||||||
|
|
||||||
return { localStart: sanitizedLocal, 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"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { format, formatDistanceToNowStrict } from "date-fns"
|
import { format, formatDistanceToNowStrict } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { PrioritySelect } from "@/components/tickets/priority-select"
|
import { PrioritySelect } from "@/components/tickets/priority-select"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { deriveServerOffset, toServerTimestamp } from "@/components/tickets/ticket-timer.utils"
|
||||||
|
|
||||||
const channelLabel: Record<TicketChannel, string> = {
|
const channelLabel: Record<TicketChannel, string> = {
|
||||||
EMAIL: "E-mail",
|
EMAIL: "E-mail",
|
||||||
|
|
@ -112,6 +113,7 @@ export type TicketsTableProps = {
|
||||||
export function TicketsTable({ tickets }: TicketsTableProps) {
|
export function TicketsTable({ tickets }: TicketsTableProps) {
|
||||||
const safeTickets = tickets ?? []
|
const safeTickets = tickets ?? []
|
||||||
const [now, setNow] = useState(() => Date.now())
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
const serverOffsetRef = useRef<number>(0)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -121,11 +123,25 @@ export function TicketsTable({ tickets }: TicketsTableProps) {
|
||||||
return () => clearInterval(interval)
|
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 getWorkedMs = (ticket: Ticket) => {
|
||||||
const base = ticket.workSummary?.totalWorkedMs ?? 0
|
const base = ticket.workSummary?.totalWorkedMs ?? 0
|
||||||
const activeStart = ticket.workSummary?.activeSession?.startedAt
|
const activeStart = ticket.workSummary?.activeSession?.startedAt
|
||||||
if (activeStart instanceof Date) {
|
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
|
return base
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ const serverTicketSchema = z.object({
|
||||||
totalWorkedMs: z.number(),
|
totalWorkedMs: z.number(),
|
||||||
internalWorkedMs: z.number().optional(),
|
internalWorkedMs: z.number().optional(),
|
||||||
externalWorkedMs: z.number().optional(),
|
externalWorkedMs: z.number().optional(),
|
||||||
|
serverNow: z.number().optional(),
|
||||||
activeSession: z
|
activeSession: z
|
||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
|
@ -143,6 +144,7 @@ export function mapTicketFromServer(input: unknown) {
|
||||||
totalWorkedMs: s.workSummary.totalWorkedMs,
|
totalWorkedMs: s.workSummary.totalWorkedMs,
|
||||||
internalWorkedMs: s.workSummary.internalWorkedMs ?? 0,
|
internalWorkedMs: s.workSummary.internalWorkedMs ?? 0,
|
||||||
externalWorkedMs: s.workSummary.externalWorkedMs ?? 0,
|
externalWorkedMs: s.workSummary.externalWorkedMs ?? 0,
|
||||||
|
serverNow: s.workSummary.serverNow,
|
||||||
activeSession: s.workSummary.activeSession
|
activeSession: s.workSummary.activeSession
|
||||||
? {
|
? {
|
||||||
...s.workSummary.activeSession,
|
...s.workSummary.activeSession,
|
||||||
|
|
@ -201,6 +203,9 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
|
||||||
workSummary: s.workSummary
|
workSummary: s.workSummary
|
||||||
? {
|
? {
|
||||||
totalWorkedMs: s.workSummary.totalWorkedMs,
|
totalWorkedMs: s.workSummary.totalWorkedMs,
|
||||||
|
internalWorkedMs: s.workSummary.internalWorkedMs ?? 0,
|
||||||
|
externalWorkedMs: s.workSummary.externalWorkedMs ?? 0,
|
||||||
|
serverNow: s.workSummary.serverNow,
|
||||||
activeSession: s.workSummary.activeSession
|
activeSession: s.workSummary.activeSession
|
||||||
? {
|
? {
|
||||||
...s.workSummary.activeSession,
|
...s.workSummary.activeSession,
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ export const ticketSchema = z.object({
|
||||||
totalWorkedMs: z.number(),
|
totalWorkedMs: z.number(),
|
||||||
internalWorkedMs: z.number().optional().default(0),
|
internalWorkedMs: z.number().optional().default(0),
|
||||||
externalWorkedMs: z.number().optional().default(0),
|
externalWorkedMs: z.number().optional().default(0),
|
||||||
|
serverNow: z.number().optional(),
|
||||||
activeSession: z
|
activeSession: z
|
||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, it } from "vitest"
|
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", () => {
|
describe("reconcileLocalSessionStart", () => {
|
||||||
it("usa o timestamp remoto quando não há marcador local", () => {
|
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" })
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue