Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions app/api/time/route.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
}
40 changes: 36 additions & 4 deletions components/custom-ui/toast/toast-poll-notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<number>(performance.now());
const initialRemainingMsRef = useRef<number>(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<number>(getSecondsRemaining());

Expand Down
2 changes: 2 additions & 0 deletions lib/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions lib/utils/time.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
// 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));
};
}