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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(),

View file

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