Skip to content
Merged
25 changes: 22 additions & 3 deletions apps/web/src/app/discover/@leaderboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getDiscoverLeaderboardQueryOptions } from "@ecency/sdk";
import { dehydrate, HydrationBoundary, useQuery } from "@tanstack/react-query";
import { getQueryClient } from "@/core/react-query";
import { DiscoverPeriodDropdown } from "@/app/discover/_components/discover-period-dropdown";
import { DayResetLabel } from "@/app/discover/_components/day-reset-label";
import i18next from "i18next";
import { Tooltip } from "@ui/tooltip";
import React from "react";
Expand All @@ -28,7 +29,22 @@ export default function LeaderboardPage({ searchParams }: Props) {
const params = useSearchParams();
const period = (params.get("period") as LeaderBoardDuration) ?? "day";

const { data } = useQuery(getDiscoverLeaderboardQueryOptions(period));
// "always": returning to Discover in the same tab must not show an earlier copy of the
// board, whose checkmarks and day can be out of date.
const { data, refetch } = useQuery({
...getDiscoverLeaderboardQueryOptions(period),
refetchOnMount: "always"
});

// Coming back to the day board from week or month reuses its cached copy, and the
// reset label was not on screen to see a midnight in between, so reload it.
const previousPeriod = React.useRef(period);
React.useEffect(() => {
if (period === "day" && previousPeriod.current !== "day") {
refetch();
}
previousPeriod.current = period;
}, [period, refetch]);

return (
<HydrationBoundary state={dehydrate(getQueryClient())}>
Expand All @@ -37,8 +53,11 @@ export default function LeaderboardPage({ searchParams }: Props) {
>
<UsersTableListLayout>
<div className="flex flex-wrap gap-2 justify-between items-center">
<div className="font-semibold">
{i18next.t(`leaderboard.title-${params.get("period") ?? "day"}`)}
<div>
<div className="font-semibold">
{i18next.t(`leaderboard.title-${params.get("period") ?? "day"}`)}
</div>
{period === "day" && <DayResetLabel onDayChange={() => refetch()} />}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
</div>
<DiscoverPeriodDropdown />
</div>
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/app/discover/_components/day-reset-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"use client";

import i18next from "i18next";
import { useUtcClock } from "@/core/hooks/use-utc-clock";

const DAY_MS = 24 * 60 * 60 * 1000;

/**
* Hours and minutes until the next 00:00 UTC, when the day leaderboard, the daily
* quests and the free spin reset. A part minute rounds up, so the last minute of the
* day reads "0h 1m", never "0h 0m".
*/
export function timeUntilUtcReset(now: number): { hours: number; minutes: number } {
const left = DAY_MS - (now % DAY_MS);
const totalMinutes = Math.ceil(left / 60000);
return { hours: Math.floor(totalMinutes / 60), minutes: totalMinutes % 60 };
}

interface Props {
/** Called once when the UTC day changes while the label is on screen. */
onDayChange?: () => void;
}

export function DayResetLabel({ onDayChange }: Props) {
const now = useUtcClock(onDayChange);

if (now === null) {
return null;
}

return (
<div className="text-xs opacity-50">
{i18next.t("leaderboard.day-resets-in", timeUntilUtcReset(now))}
</div>
);
}
20 changes: 19 additions & 1 deletion apps/web/src/app/perks/components/perks-points-spin-banner.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useActiveAccount } from "@/core/hooks/use-active-account";
import { useUtcClock } from "@/core/hooks/use-utc-clock";

import { PointsSpin, SPIN_VALUES } from "@/features/points";
import { error, success } from "@/features/shared";
Expand Down Expand Up @@ -88,6 +89,20 @@ export function PerksPointsSpinBanner() {
}
}, [activeUser?.username, claim, queryClient]);

// The free spin resets at 00:00 UTC. A status loaded before then still says no spins
// are left, so reload it at the reset; the countdown reads the same query.
useUtcClock(
useCallback(() => {
queryClient.refetchQueries({
queryKey: getGameStatusCheckQueryOptions(
activeUser?.username,
getAccessToken(activeUser?.username ?? ""),
"spin"
).queryKey
});
}, [activeUser?.username, queryClient])
);

// after an account switch the other account is not mid-claim, and its status is
// not stale just because it was loaded before this claim
const username = activeUser?.username ?? "";
Expand Down Expand Up @@ -135,7 +150,10 @@ export function PerksPointsSpinBanner() {
</ModalBody>
<ModalFooter className="flex justify-between items-center">
<div>
{data?.remaining ?? 0} {i18next.t("perks.spins-left")}
<div>
{data?.remaining ?? 0} {i18next.t("perks.spins-left")}
</div>
<div className="text-xs opacity-50">{i18next.t("perks.spin-resets-utc")}</div>
</div>
<Button
disabled={!canSpin || isClaiming || isStatusStale}
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/core/hooks/use-utc-clock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use client";

import { useEffect, useRef, useState } from "react";

const MINUTE_MS = 60 * 1000;
const DAY_MS = 24 * 60 * MINUTE_MS;

/**
* The time, updated on every UTC minute boundary, for anything tied to the 00:00 UTC
* reset (day leaderboard, daily quests, free spin). `onDayChange` runs once when the UTC day
* changes while the component is mounted, so it can reload what reset.
*
* Null until mounted: server HTML can be served later from a cache, so whatever
* renders a time from this renders it from the browser's clock only.
*/
export function useUtcClock(onDayChange?: () => void): number | null {
const [now, setNow] = useState<number | null>(null);
const onDayChangeRef = useRef(onDayChange);

useEffect(() => {
onDayChangeRef.current = onDayChange;
}, [onDayChange]);

useEffect(() => {
let day: number | null = null;
const tick = () => {
const t = Date.now();
const today = Math.floor(t / DAY_MS);
if (day !== null && today !== day) {
onDayChangeRef.current?.();
}
day = today;
setNow(t);
};
// Each tick lands on the next minute boundary, not 60 s after mount, so the
// reset is seen at 00:00:00 UTC however far into a minute the page opened.
let id: ReturnType<typeof setTimeout>;
const schedule = () => {
id = setTimeout(() => {
tick();
schedule();
}, MINUTE_MS - (Date.now() % MINUTE_MS));
};
tick();
schedule();
return () => clearTimeout(id);
}, []);

return now;
}
2 changes: 2 additions & 0 deletions apps/web/src/features/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -2255,6 +2255,7 @@
"title-day": "Daily leaders",
"title-week": "Weekly leaders",
"title-month": "Monthly leaders",
"day-resets-in": "Today (UTC), resets in {{hours}}h {{minutes}}m",
"period-day": "Daily",
"period-week": "Weekly",
"period-month": "Monthly",
Expand Down Expand Up @@ -3413,6 +3414,7 @@
"spin-error": "Could not complete the spin. Please try again.",
"spins-left": "spins left",
"next-spin": "Next free spin",
"spin-resets-utc": "Resets daily at 00:00 UTC",
"quests": {
"title": "Quests",
"subtitle": "Complete daily goals, earn points and keep your streak alive",
Expand Down
103 changes: 103 additions & 0 deletions apps/web/src/specs/app/discover/day-reset-label.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import i18next from "i18next";
import { renderToString } from "react-dom/server";
import { DayResetLabel, timeUntilUtcReset } from "@/app/discover/_components/day-reset-label";

const at = (iso: string) => new Date(iso).getTime();

describe("timeUntilUtcReset", () => {
it("counts down to the next 00:00 UTC", () => {
expect(timeUntilUtcReset(at("2026-10-30T18:48:00Z"))).toEqual({ hours: 5, minutes: 12 });
});

it("rounds a part minute up, so the last minute never reads 0h 0m", () => {
expect(timeUntilUtcReset(at("2026-10-30T23:59:30Z"))).toEqual({ hours: 0, minutes: 1 });
});

it("is a full day right at the reset", () => {
expect(timeUntilUtcReset(at("2026-10-30T00:00:00Z"))).toEqual({ hours: 24, minutes: 0 });
});

it("does not depend on the viewer's time zone", () => {
// 01:30 in Berlin on 10-31 is still 23:30 UTC on 10-30
expect(timeUntilUtcReset(at("2026-10-31T01:30:00+02:00"))).toEqual({ hours: 0, minutes: 30 });
});
});

describe("DayResetLabel", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(i18next.t).mockClear();
});

afterEach(() => {
vi.useRealTimers();
});

it("shows the UTC day and the time left", () => {
vi.setSystemTime(new Date("2026-10-30T18:48:00Z"));
render(<DayResetLabel />);

expect(screen.getByText("leaderboard.day-resets-in")).toBeInTheDocument();
expect(i18next.t).toHaveBeenCalledWith("leaderboard.day-resets-in", { hours: 5, minutes: 12 });
});

it("keeps counting while the page stays open", () => {
vi.setSystemTime(new Date("2026-10-30T18:48:00Z"));
render(<DayResetLabel />);

act(() => {
vi.advanceTimersByTime(60 * 60 * 1000);
});

expect(i18next.t).toHaveBeenLastCalledWith("leaderboard.day-resets-in", { hours: 4, minutes: 12 });
});

it("puts no time in the server HTML, which can be served later from a cache", () => {
vi.setSystemTime(new Date("2026-10-30T18:48:00Z"));
const html = renderToString(<DayResetLabel />);

expect(html).not.toContain("leaderboard.day-resets-in");
expect(i18next.t).not.toHaveBeenCalled();
});

it("tells the page once when the UTC day changes, so it can load the new board", () => {
vi.setSystemTime(new Date("2026-10-30T23:58:30Z"));
const onDayChange = vi.fn();
render(<DayResetLabel onDayChange={onDayChange} />);

act(() => {
vi.advanceTimersByTime(60 * 1000);
});
expect(onDayChange).not.toHaveBeenCalled();

act(() => {
vi.advanceTimersByTime(60 * 1000);
});
expect(onDayChange).toHaveBeenCalledTimes(1);

act(() => {
vi.advanceTimersByTime(10 * 60 * 1000);
});
expect(onDayChange).toHaveBeenCalledTimes(1);
});

it("fires the day change at 00:00:00 UTC, not up to a minute later", () => {
vi.setSystemTime(new Date("2026-10-30T23:59:30Z"));
const onDayChange = vi.fn();
render(<DayResetLabel onDayChange={onDayChange} />);

act(() => {
vi.advanceTimersByTime(29_999);
});
expect(onDayChange).not.toHaveBeenCalled();

act(() => {
vi.advanceTimersByTime(1);
});
expect(onDayChange).toHaveBeenCalledTimes(1);
expect(i18next.t).toHaveBeenLastCalledWith("leaderboard.day-resets-in", { hours: 24, minutes: 0 });
});
});
Loading
Loading