diff --git a/app/api/time/route.ts b/app/api/time/route.ts new file mode 100644 index 0000000..2765a4c --- /dev/null +++ b/app/api/time/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; + +export function GET() { + return new NextResponse(JSON.stringify({ serverNowMs: Date.now() }), { + status: 200, + headers: { + "content-type": "application/json", + "cache-control": "no-store", + }, + }); +} diff --git a/components/custom-ui/toast/toast-poll-notification.tsx b/components/custom-ui/toast/toast-poll-notification.tsx index 58c98f3..b8c360f 100644 --- a/components/custom-ui/toast/toast-poll-notification.tsx +++ b/components/custom-ui/toast/toast-poll-notification.tsx @@ -2,13 +2,13 @@ import { AnimatePresence, easeIn, motion } from "motion/react"; import { QRCodeSVG } from "qrcode.react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { NumberTicker } from "@/components/shadcn-ui/number-ticker"; import { useSocket } from "@/hooks/use-socket"; import { useSocketUtils } from "@/hooks/use-socket-utils"; import { ServerToClientSocketEvents } from "@/lib/enums"; import { UpdatePollNotificationEvent } from "@/lib/types/socket"; -import { cn } from "@/lib/utils"; +import { cn, getServerOffsetMs } from "@/lib/utils"; import { BearIcon } from "../icons/bear-icon"; import { BullIcon } from "../icons/bull-icon"; @@ -164,9 +164,41 @@ export const ToastPollNotification = ({ const xOffset = isLeft ? 100 : isRight ? -100 : 0; const yOffset = isCenter ? (isTop ? 100 : -100) : 0; + // Use a monotonic clock for ticking to avoid issues if the user changes system time. + // Compute the initial remaining time once, then decrease using performance.now(). + const perfStartRef = useRef(performance.now()); + const initialRemainingMsRef = useRef(data.endTimeMs - Date.now()); + + // Reset the baseline if the poll end time changes, using a server time offset + useEffect(() => { + let cancelled = false; + + // Prefer the shared util for computing server time offset + async function initializeCountdownBaseline() { + const offsetMs = await getServerOffsetMs(); + if (cancelled) return; + perfStartRef.current = performance.now(); + initialRemainingMsRef.current = data.endTimeMs - (Date.now() + offsetMs); + // Force an immediate recompute so UI reflects corrected baseline + setSecondsLeft( + Math.max(0, Math.ceil(initialRemainingMsRef.current / 1000)), + ); + } + + initializeCountdownBaseline(); + + return () => { + cancelled = true; + }; + }, [data.endTimeMs]); + const getSecondsRemaining = useMemo(() => { - return () => Math.max(0, Math.ceil((data.endTimeMs - Date.now()) / 1000)); - }, [data]); + return () => { + const elapsedMs = performance.now() - perfStartRef.current; + const remainingMs = initialRemainingMsRef.current - elapsedMs; + return Math.max(0, Math.ceil(remainingMs / 1000)); + }; + }, []); const [secondsLeft, setSecondsLeft] = useState(getSecondsRemaining()); diff --git a/lib/utils/index.ts b/lib/utils/index.ts index 388b907..3c4a938 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -248,6 +248,8 @@ export const slugify = (text: string) => { .replace(/_+/g, "_"); }; +export { createMonotonicSecondsGetter, getServerOffsetMs } from "./time"; + /** * Calculate the time left for a poll * @param deadline - The deadline of the poll diff --git a/lib/utils/time.ts b/lib/utils/time.ts new file mode 100644 index 0000000..8d2b951 --- /dev/null +++ b/lib/utils/time.ts @@ -0,0 +1,53 @@ +/** + * Compute server time offset (serverNowMs - clientNowMs) with RTT midpoint correction. + * Tries /api/time first, then falls back to HEAD / Date header. Returns 0 on failure. + */ +export async function getServerOffsetMs(): Promise { + // Prefer dedicated endpoint + try { + const t0 = performance.now(); + const resp = await fetch("/api/time", { cache: "no-store" }); + const t1 = performance.now(); + if (resp.ok) { + const json = await resp.json(); + const serverNowMs = Number(json?.serverNowMs); + if (Number.isFinite(serverNowMs)) { + const rttMs = t1 - t0; + const clientMidNowMs = Date.now() - rttMs / 2; + return serverNowMs - clientMidNowMs; + } + } + } catch {} + + // Fallback to Date header + try { + const t0 = performance.now(); + const res = await fetch("/", { method: "HEAD", cache: "no-store" }); + const t1 = performance.now(); + const dateHeader = res.headers.get("date"); + if (!dateHeader) return 0; + const rttMs = t1 - t0; + const clientMidNowMs = Date.now() - rttMs / 2; + const serverNowMs = new Date(dateHeader).getTime(); + return serverNowMs - clientMidNowMs; + } catch { + return 0; + } +} + +/** + * Start a monotonic countdown based on an absolute end time and a server offset. + * Returns a getter that yields whole seconds remaining, clamped at 0. + */ +export function createMonotonicSecondsGetter( + endTimeMs: number, + offsetMs: number, +) { + const perfStart = performance.now(); + const initialRemainingMs = endTimeMs - (Date.now() + offsetMs); + return () => { + const elapsedMs = performance.now() - perfStart; + const remainingMs = initialRemainingMs - elapsedMs; + return Math.max(0, Math.ceil(remainingMs / 1000)); + }; +}