From 53d847cb16504755a2ba6f85bf9ba93c6a671b94 Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 30 Oct 2026 09:44:18 +0000 Subject: [PATCH 1/8] Specs for #1844: UTC day reset label, leaderboard refetch on mount, spin reset hint --- .../discover/_components/day-reset-label.tsx | 9 ++ .../app/discover/day-reset-label.spec.tsx | 56 +++++++++++ .../app/discover/leaderboard-page.spec.tsx | 95 +++++++++++++++++++ .../perks/perks-points-spin-banner.spec.tsx | 7 ++ 4 files changed, 167 insertions(+) create mode 100644 apps/web/src/app/discover/_components/day-reset-label.tsx create mode 100644 apps/web/src/specs/app/discover/day-reset-label.spec.tsx create mode 100644 apps/web/src/specs/app/discover/leaderboard-page.spec.tsx 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..5032291946 --- /dev/null +++ b/apps/web/src/app/discover/_components/day-reset-label.tsx @@ -0,0 +1,9 @@ +"use client"; + +export function timeUntilUtcReset(_now: number): { hours: number; minutes: number } { + return { hours: 0, minutes: 0 }; +} + +export function DayResetLabel() { + return null; +} diff --git a/apps/web/src/specs/app/discover/day-reset-label.spec.tsx b/apps/web/src/specs/app/discover/day-reset-label.spec.tsx new file mode 100644 index 0000000000..21ae0d7b18 --- /dev/null +++ b/apps/web/src/specs/app/discover/day-reset-label.spec.tsx @@ -0,0 +1,56 @@ +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 { 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(); + + 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(); + + act(() => { + vi.advanceTimersByTime(60 * 60 * 1000); + }); + + expect(i18next.t).toHaveBeenLastCalledWith("leaderboard.day-resets-in", { hours: 4, minutes: 12 }); + }); +}); diff --git a/apps/web/src/specs/app/discover/leaderboard-page.spec.tsx b/apps/web/src/specs/app/discover/leaderboard-page.spec.tsx new file mode 100644 index 0000000000..bba767e9db --- /dev/null +++ b/apps/web/src/specs/app/discover/leaderboard-page.spec.tsx @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mocks = vi.hoisted(() => ({ + period: null as string | null, + queryClient: null as any, + queryFn: null as any +})); + +vi.mock("@/config", () => ({ + EcencyConfigManager: { + Conditional: ({ children }: any) => <>{children} + } +})); + +vi.mock("@/app/discover/_components", () => ({ + UsersTableListLayout: ({ children }: any) =>
{children}
, + UsersTableListItem: ({ children, username }: any) =>
{children}
, + UserTableListHeader: ({ children }: any) =>
{children}
+})); + +vi.mock("@/app/discover/_components/day-reset-label", () => ({ + DayResetLabel: () => +})); + +vi.mock("@/app/discover/_components/discover-period-dropdown", () => ({ + DiscoverPeriodDropdown: () => null +})); + +vi.mock("@tooni/iconscout-unicons-react", () => ({ + UilInfoCircle: () => null, + UilCheckCircle: () => null +})); +vi.mock("@ui/tooltip", () => ({ Tooltip: ({ children }: any) => <>{children} })); +vi.mock("@ui/badge", () => ({ Badge: ({ children }: any) => {children} })); +vi.mock("@ui/svg", () => ({ medalSvg: null })); + +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(mocks.period ? { period: mocks.period } : {}), + useParams: () => ({}) +})); + +vi.mock("@/core/react-query", () => ({ + getQueryClient: () => mocks.queryClient +})); + +vi.mock("@ecency/sdk", () => ({ + getDiscoverLeaderboardQueryOptions: (duration: string) => ({ + queryKey: ["analytics", "discover-leaderboard", duration], + queryFn: () => mocks.queryFn(duration) + }) +})); + +import LeaderboardPage from "@/app/discover/@leaderboard/page"; + +const rows = [{ _id: "alice", count: 3, points: "1.000", quests_done: true }]; + +function renderPage() { + return render( + + + + ); +} + +describe("Leaderboard page, UTC day reset", () => { + beforeEach(() => { + // the app's client never refetches fresh data on mount by default + mocks.queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity, refetchOnMount: false } } + }); + mocks.queryFn = vi.fn(async () => rows); + mocks.period = null; + }); + + it("shows the UTC reset label on the day board", () => { + renderPage(); + expect(screen.getByTestId("day-reset-label")).toBeInTheDocument(); + }); + + it.each(["week", "month"])("shows no reset label on the %s board", (period) => { + mocks.period = period; + renderPage(); + expect(screen.queryByTestId("day-reset-label")).not.toBeInTheDocument(); + }); + + it("refetches the day board on mount instead of showing an earlier copy", async () => { + mocks.queryClient.setQueryData(["analytics", "discover-leaderboard", "day"], []); + renderPage(); + await waitFor(() => expect(mocks.queryFn).toHaveBeenCalledWith("day")); + }); +}); diff --git a/apps/web/src/specs/app/perks/perks-points-spin-banner.spec.tsx b/apps/web/src/specs/app/perks/perks-points-spin-banner.spec.tsx index 05f35344a1..ccb11291ca 100644 --- a/apps/web/src/specs/app/perks/perks-points-spin-banner.spec.tsx +++ b/apps/web/src/specs/app/perks/perks-points-spin-banner.spec.tsx @@ -332,4 +332,11 @@ 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(); + const hint = screen.getByText("perks.spin-resets-utc"); + // same footer as the spin button that carries the countdown + expect(hint.closest("div")?.parentElement).toContainElement(screen.getByRole("button", { name: "Spin" })); + }); }); From 080befcbc315aec5321014cbb60f1cfac6950fd0 Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 30 Oct 2026 09:47:33 +0000 Subject: [PATCH 2/8] Show the 00:00 UTC reset on the day leaderboard and the free spin, refetch the board on mount (#1844) --- .../src/app/discover/@leaderboard/page.tsx | 15 ++++++++-- .../discover/_components/day-reset-label.tsx | 30 +++++++++++++++++-- .../components/perks-points-spin-banner.tsx | 5 +++- apps/web/src/features/i18n/locales/en-US.json | 2 ++ .../perks/perks-points-spin-banner.spec.tsx | 11 +++---- 5 files changed, 51 insertions(+), 12 deletions(-) diff --git a/apps/web/src/app/discover/@leaderboard/page.tsx b/apps/web/src/app/discover/@leaderboard/page.tsx index 2233c96310..f6c7fc28dc 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,12 @@ 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 } = useQuery({ + ...getDiscoverLeaderboardQueryOptions(period), + refetchOnMount: "always" + }); return ( @@ -37,8 +43,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" && }
diff --git a/apps/web/src/app/discover/_components/day-reset-label.tsx b/apps/web/src/app/discover/_components/day-reset-label.tsx index 5032291946..57a397d468 100644 --- a/apps/web/src/app/discover/_components/day-reset-label.tsx +++ b/apps/web/src/app/discover/_components/day-reset-label.tsx @@ -1,9 +1,33 @@ "use client"; -export function timeUntilUtcReset(_now: number): { hours: number; minutes: number } { - return { hours: 0, minutes: 0 }; +import i18next from "i18next"; +import { useEffect, useState } from "react"; + +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 }; } export function DayResetLabel() { - return null; + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 60 * 1000); + return () => clearInterval(id); + }, []); + + return ( + // The minute can differ between the server render and the browser; the browser value wins. +
+ {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..1fbede0374 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 @@ -135,7 +135,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}
})); @@ -335,8 +335,9 @@ describe("PerksPointsSpinBanner", () => { it("says the free spin resets daily at 00:00 UTC next to the countdown", () => { render(); - const hint = screen.getByText("perks.spin-resets-utc"); - // same footer as the spin button that carries the countdown - expect(hint.closest("div")?.parentElement).toContainElement(screen.getByRole("button", { name: "Spin" })); + // 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(); }); }); From a21d192f0e4d3c767914052aac1fdae095868fee Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 30 Oct 2026 09:48:56 +0000 Subject: [PATCH 3/8] Specs for #1844 review: no time in server HTML, reload the board when the UTC day changes --- .../app/discover/day-reset-label.spec.tsx | 30 +++++++++++++++++++ .../app/discover/leaderboard-page.spec.tsx | 14 +++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/web/src/specs/app/discover/day-reset-label.spec.tsx b/apps/web/src/specs/app/discover/day-reset-label.spec.tsx index 21ae0d7b18..5e4931661c 100644 --- a/apps/web/src/specs/app/discover/day-reset-label.spec.tsx +++ b/apps/web/src/specs/app/discover/day-reset-label.spec.tsx @@ -2,6 +2,7 @@ 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(); @@ -53,4 +54,33 @@ describe("DayResetLabel", () => { 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(); + + 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(); + + 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); + }); }); diff --git a/apps/web/src/specs/app/discover/leaderboard-page.spec.tsx b/apps/web/src/specs/app/discover/leaderboard-page.spec.tsx index bba767e9db..923903a36a 100644 --- a/apps/web/src/specs/app/discover/leaderboard-page.spec.tsx +++ b/apps/web/src/specs/app/discover/leaderboard-page.spec.tsx @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import React from "react"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -23,7 +23,9 @@ vi.mock("@/app/discover/_components", () => ({ })); vi.mock("@/app/discover/_components/day-reset-label", () => ({ - DayResetLabel: () => + DayResetLabel: ({ onDayChange }: { onDayChange?: () => void }) => ( +
diff --git a/apps/web/src/app/discover/_components/day-reset-label.tsx b/apps/web/src/app/discover/_components/day-reset-label.tsx index 57a397d468..1cf24ef345 100644 --- a/apps/web/src/app/discover/_components/day-reset-label.tsx +++ b/apps/web/src/app/discover/_components/day-reset-label.tsx @@ -1,7 +1,7 @@ "use client"; import i18next from "i18next"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -16,17 +16,43 @@ export function timeUntilUtcReset(now: number): { hours: number; minutes: number return { hours: Math.floor(totalMinutes / 60), minutes: totalMinutes % 60 }; } -export function DayResetLabel() { - const [now, setNow] = useState(() => Date.now()); +interface Props { + /** Called once when the UTC day changes while the label is on screen. */ + onDayChange?: () => void; +} + +export function DayResetLabel({ onDayChange }: Props) { + // null until mounted: the server HTML can be served later from a cache, so it + // carries no time, and the browser renders the countdown from its own clock. + const [now, setNow] = useState(null); + const onDayChangeRef = useRef(onDayChange); useEffect(() => { - const id = setInterval(() => setNow(Date.now()), 60 * 1000); + 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); + }; + tick(); + const id = setInterval(tick, 60 * 1000); return () => clearInterval(id); }, []); + if (now === null) { + return null; + } + return ( - // The minute can differ between the server render and the browser; the browser value wins. -
+
{i18next.t("leaderboard.day-resets-in", timeUntilUtcReset(now))}
); diff --git a/apps/web/src/specs/app/discover/leaderboard-page.spec.tsx b/apps/web/src/specs/app/discover/leaderboard-page.spec.tsx index 923903a36a..58a18ed644 100644 --- a/apps/web/src/specs/app/discover/leaderboard-page.spec.tsx +++ b/apps/web/src/specs/app/discover/leaderboard-page.spec.tsx @@ -24,7 +24,7 @@ vi.mock("@/app/discover/_components", () => ({ vi.mock("@/app/discover/_components/day-reset-label", () => ({ DayResetLabel: ({ onDayChange }: { onDayChange?: () => void }) => ( -