diff --git a/apps/web/src/app/discover/@leaderboard/page.tsx b/apps/web/src/app/discover/@leaderboard/page.tsx index 2233c96310..2cdbbf0a6d 100644 --- a/apps/web/src/app/discover/@leaderboard/page.tsx +++ b/apps/web/src/app/discover/@leaderboard/page.tsx @@ -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"; @@ -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 ( @@ -37,8 +53,11 @@ export default function LeaderboardPage({ searchParams }: Props) { >
-
- {i18next.t(`leaderboard.title-${params.get("period") ?? "day"}`)} +
+
+ {i18next.t(`leaderboard.title-${params.get("period") ?? "day"}`)} +
+ {period === "day" && refetch()} />}
diff --git a/apps/web/src/app/discover/_components/day-reset-label.tsx b/apps/web/src/app/discover/_components/day-reset-label.tsx new file mode 100644 index 0000000000..d843b46ff0 --- /dev/null +++ b/apps/web/src/app/discover/_components/day-reset-label.tsx @@ -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 ( +
+ {i18next.t("leaderboard.day-resets-in", timeUntilUtcReset(now))} +
+ ); +} diff --git a/apps/web/src/app/perks/components/perks-points-spin-banner.tsx b/apps/web/src/app/perks/components/perks-points-spin-banner.tsx index 2f778b74e0..ea434615a5 100644 --- a/apps/web/src/app/perks/components/perks-points-spin-banner.tsx +++ b/apps/web/src/app/perks/components/perks-points-spin-banner.tsx @@ -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"; @@ -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 ?? ""; @@ -135,7 +150,10 @@ export function PerksPointsSpinBanner() {
- {data?.remaining ?? 0} {i18next.t("perks.spins-left")} +
+ {data?.remaining ?? 0} {i18next.t("perks.spins-left")} +
+
{i18next.t("perks.spin-resets-utc")}
, Modal: ({ children }: any) =>
{children}
, ModalBody: ({ children }: any) =>
{children}
, - ModalFooter: ({ children }: any) =>
{children}
, + ModalFooter: ({ children }: any) =>
{children}
, ModalHeader: ({ children }: any) =>
{children}
, StyledTooltip: ({ children }: any) =>
{children}
})); @@ -332,4 +332,34 @@ describe("PerksPointsSpinBanner", () => { await waitFor(() => expect(success).toHaveBeenCalledTimes(1)); expect(claim).toHaveBeenCalledTimes(2); }); + + it("says the free spin resets daily at 00:00 UTC next to the countdown", () => { + render(); + // in the footer with the spin button that carries the countdown + const footer = screen.getByTestId("modal-footer"); + expect(within(footer).getByText("perks.spin-resets-utc")).toBeInTheDocument(); + expect(within(footer).getByRole("button", { name: "Spin" })).toBeInTheDocument(); + }); + + it("reloads the spin status at 00:00 UTC, when the free spin resets", () => { + vi.useFakeTimers(); + try { + // opened mid-minute: the reload still lands at 00:00:00, not up to a minute later + vi.setSystemTime(new Date("2026-10-30T23:59:30Z")); + render(); + + act(() => { + vi.advanceTimersByTime(29_999); + }); + expect(refetch).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(refetch).toHaveBeenCalledTimes(1); + expect(refetch).toHaveBeenCalledWith({ queryKey: ["games", "status", "spin", "alice"] }); + } finally { + vi.useRealTimers(); + } + }); });