+
+
+
+ Session expires in{" "}
+ {formatCountdown((remainingMs ?? 0) / 1000)}.{" "}
+ Extend to stay signed in for another {minutesLeft} min.
+
+
+
+
+
+
+ {error &&
{error}
}
+
+ );
+}
diff --git a/src/hooks/useGameTick.ts b/src/hooks/useGameTick.ts
index 7bdd047..61de5eb 100644
--- a/src/hooks/useGameTick.ts
+++ b/src/hooks/useGameTick.ts
@@ -12,6 +12,8 @@ export const PROFILE_UPDATE_EVENT = "ptf:profile-update";
export const GAME_EVENT_EVENT = "ptf:game-event";
export const NOTIFICATION_EVENT = "ptf:notification";
export const SESSION_EXPIRED_EVENT = "ptf:session-expired";
+/** Dispatched after a successful Extend so minigames / consumers un-lock. */
+export const SESSION_EXTENDED_EVENT = "ptf:session-extended";
export const POKER_STATE_EVENT = "ptf:poker-state";
export const POKER_TABLES_EVENT = "ptf:poker-tables";
export const POKER_TERMINATED_EVENT = "ptf:poker-terminated";
diff --git a/src/hooks/useSessionExpired.ts b/src/hooks/useSessionExpired.ts
index cce9e37..38f70c1 100644
--- a/src/hooks/useSessionExpired.ts
+++ b/src/hooks/useSessionExpired.ts
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
-import { SESSION_EXPIRED_EVENT } from "@/hooks/useGameTick";
+import { SESSION_EXPIRED_EVENT, SESSION_EXTENDED_EVENT } from "@/hooks/useGameTick";
/**
* Tracks whether the session has expired (per the client-side watchdog).
@@ -19,13 +19,19 @@ export function useSessionExpired(): {
const expiredRef = useRef(false);
useEffect(() => {
- const handler = () => {
+ const lock = () => {
expiredRef.current = true;
setExpired(true);
};
- window.addEventListener(SESSION_EXPIRED_EVENT, handler);
+ const unlock = () => {
+ expiredRef.current = false;
+ setExpired(false);
+ };
+ window.addEventListener(SESSION_EXPIRED_EVENT, lock);
+ window.addEventListener(SESSION_EXTENDED_EVENT, unlock);
return () => {
- window.removeEventListener(SESSION_EXPIRED_EVENT, handler);
+ window.removeEventListener(SESSION_EXPIRED_EVENT, lock);
+ window.removeEventListener(SESSION_EXTENDED_EVENT, unlock);
};
}, []);
diff --git a/src/lib/session/countdown.ts b/src/lib/session/countdown.ts
new file mode 100644
index 0000000..c6af7fe
--- /dev/null
+++ b/src/lib/session/countdown.ts
@@ -0,0 +1,75 @@
+/**
+ * Session-countdown helpers.
+ *
+ * The `payload-token` cookie is HttpOnly, so the browser cannot read when the
+ * session ends. The server computes the absolute expiry (`sessionExp`, ms since
+ * epoch) and passes it to the client, which uses these pure helpers to drive a
+ * live countdown and decide when to warn / auto-close the session banner.
+ *
+ * Everything here is side-effect free and client-safe so it is unit-testable
+ * and shareable between the banner and any consumer.
+ */
+
+/** Warnings show this long before the hard cutoff so the user can Extend. */
+export const SESSION_WARN_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
+
+/** The banner auto-closes this long before expiry (Extend no longer helps). */
+export const SESSION_AUTO_CLOSE_MS = 30 * 1000; // 30 seconds
+
+/** Phases of the session-expiry window, driven by remaining time. */
+export type SessionExpiryPhase = "idle" | "warn" | "close";
+
+/**
+ * Milliseconds between countdown ticks. Kept separate from the phase constants
+ * so the visual countdown can be tuned independently of the warn/auto-close
+ * thresholds.
+ */
+export const COUNTDOWN_TICK_MS = 1000;
+
+/**
+ * Remaining time until the session expires, in milliseconds.
+ *
+ * Returns `null` when there is no known expiry (guest / missing cookie) so
+ * callers can treat "unknown" and "idle" the same.
+ */
+export function timeUntilExpiry(
+ sessionExp: number | null | undefined,
+ now: number = Date.now(),
+): number | null {
+ if (sessionExp == null || !Number.isFinite(sessionExp)) return null;
+ return sessionExp - now;
+}
+
+/**
+ * Which phase of the expiry window a given remaining time falls into.
+ *
+ * - `close`: about to expire (<= closeMs) -> banner auto-closes.
+ * - `warn`: within the warn window (closeMs < remaining <= warnMs) -> shown.
+ * - `idle`: plenty of time left (remaining > warnMs) or unknown.
+ */
+export function sessionExpiryPhase(
+ remainingMs: number | null,
+ warnMs: number = SESSION_WARN_THRESHOLD_MS,
+ closeMs: number = SESSION_AUTO_CLOSE_MS,
+): SessionExpiryPhase {
+ if (remainingMs == null) return "idle";
+ if (remainingMs <= closeMs) return "close";
+ if (remainingMs <= warnMs) return "warn";
+ return "idle";
+}
+
+/**
+ * Format a duration in seconds as `H:MM:SS` (leading hour dropped when < 1h)
+ * for a live countdown, e.g. `14:59` or `1:02:05`. Always rounds up to the next
+ * second and never goes negative.
+ */
+export function formatCountdown(totalSeconds: number): string {
+ if (!Number.isFinite(totalSeconds)) return "00:00";
+ const safe = Math.max(0, Math.ceil(totalSeconds));
+ const hours = Math.floor(safe / 3600);
+ const minutes = Math.floor((safe % 3600) / 60);
+ const seconds = safe % 60;
+ const mm = String(minutes).padStart(2, "0");
+ const ss = String(seconds).padStart(2, "0");
+ return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
+}
diff --git a/tests/e2e/banner_check.spec.mjs b/tests/e2e/banner_check.spec.mjs
new file mode 100644
index 0000000..3ed36c7
--- /dev/null
+++ b/tests/e2e/banner_check.spec.mjs
@@ -0,0 +1,53 @@
+import { test, expect } from "@playwright/test";
+
+test("banner shows countdown + extend button", async ({ page, context }) => {
+ // Craft near-expiry token and set it as cookie
+ const { SignJWT } = await import("jose");
+ const SECRET = "f80003be79c28ca283c2cbfde2984356";
+ const key = new TextEncoder().encode(SECRET);
+
+ // Login to get a real sid
+ const loginRes = await fetch("http://localhost:3000/api/users/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username: "dev", password: "Test123" }),
+ });
+ const data = await loginRes.json();
+ const payload = JSON.parse(Buffer.from(data.token.split(".")[1], "base64url").toString());
+
+ const now = Math.floor(Date.now() / 1000);
+ const near = await new SignJWT({
+ id: 23, collection: "users", email: payload.email, sid: payload.sid, iat: now, exp: now + 600,
+ })
+ .setProtectedHeader({ alg: "HS256", typ: "JWT" })
+ .setIssuedAt(now)
+ .setExpirationTime(now + 600)
+ .sign(key);
+
+ // Set cookie
+ await context.addCookies([{
+ name: "payload-token",
+ value: near,
+ domain: "localhost",
+ path: "/",
+ expires: now + 600,
+ }]);
+
+ // Navigate to home
+ await page.goto("http://localhost:3000/");
+ await page.waitForSelector("role=status", { timeout: 10000 });
+
+ // Check banner is visible
+ const banner = page.locator("role=status").first();
+ await expect(banner).toBeVisible();
+
+ // Check countdown
+ const countdown = banner.locator(".font-mono.tabular-nums").first();
+ await expect(countdown).toHaveText(/0\d:\d{2}/); // should show ~10:00
+
+ // Check extend button
+ const extendBtn = banner.locator("button:has-text('Extend')");
+ await expect(extendBtn).toBeVisible();
+
+ console.log("Banner check PASSED");
+});
diff --git a/tests/int/session-countdown.int.spec.ts b/tests/int/session-countdown.int.spec.ts
new file mode 100644
index 0000000..fdc2482
--- /dev/null
+++ b/tests/int/session-countdown.int.spec.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from "vitest";
+import {
+ SESSION_AUTO_CLOSE_MS,
+ SESSION_WARN_THRESHOLD_MS,
+ formatCountdown,
+ sessionExpiryPhase,
+ timeUntilExpiry,
+} from "@/lib/session/countdown";
+
+describe("timeUntilExpiry", () => {
+ it("returns sessionExp - now", () => {
+ expect(timeUntilExpiry(1_800_000_000, 1_700_000_000)).toBe(100_000_000);
+ });
+
+ it("defaults now to Date.now()", () => {
+ const now = Date.now();
+ const result = timeUntilExpiry(now + 5_000);
+ expect(result).toBeGreaterThanOrEqual(4_900);
+ expect(result).toBeLessThanOrEqual(5_000);
+ });
+
+ it("returns null when there is no known expiry", () => {
+ expect(timeUntilExpiry(null)).toBeNull();
+ expect(timeUntilExpiry(undefined)).toBeNull();
+ });
+
+ it("returns null for a non-finite expiry", () => {
+ expect(timeUntilExpiry(Number.NaN)).toBeNull();
+ expect(timeUntilExpiry(Number.POSITIVE_INFINITY)).toBeNull();
+ });
+
+ it("allows a negative remaining time (already expired)", () => {
+ expect(timeUntilExpiry(100, 200)).toBe(-100);
+ });
+});
+
+describe("sessionExpiryPhase", () => {
+ const warn = SESSION_WARN_THRESHOLD_MS; // 15 min
+ const close = SESSION_AUTO_CLOSE_MS; // 30s
+
+ it("returns idle when remaining exceeds the warn window", () => {
+ expect(sessionExpiryPhase(warn + 1)).toBe("idle");
+ expect(sessionExpiryPhase(null)).toBe("idle");
+ });
+
+ it("returns warn inside the window (close < remaining <= warn)", () => {
+ expect(sessionExpiryPhase(warn)).toBe("warn");
+ expect(sessionExpiryPhase(close + 1)).toBe("warn");
+ expect(sessionExpiryPhase(warn / 2)).toBe("warn");
+ });
+
+ it("returns close at or below the close threshold", () => {
+ expect(sessionExpiryPhase(close)).toBe("close");
+ expect(sessionExpiryPhase(0)).toBe("close");
+ expect(sessionExpiryPhase(-1)).toBe("close");
+ });
+
+ it("honours custom thresholds", () => {
+ expect(sessionExpiryPhase(10 * 60_000, 15 * 60_000, 5 * 60_000)).toBe("warn");
+ expect(sessionExpiryPhase(3 * 60_000, 15 * 60_000, 5 * 60_000)).toBe("close");
+ });
+});
+
+describe("formatCountdown", () => {
+ it("formats under an hour as MM:SS", () => {
+ expect(formatCountdown(0)).toBe("00:00");
+ expect(formatCountdown(59)).toBe("00:59");
+ expect(formatCountdown(60)).toBe("01:00");
+ expect(formatCountdown(90)).toBe("01:30");
+ expect(formatCountdown(599)).toBe("09:59");
+ });
+
+ it("formats hours as H:MM:SS", () => {
+ expect(formatCountdown(3600)).toBe("1:00:00");
+ expect(formatCountdown(3725)).toBe("1:02:05");
+ });
+
+ it("rounds up to the next whole second", () => {
+ expect(formatCountdown(0.1)).toBe("00:01");
+ expect(formatCountdown(59.4)).toBe("01:00");
+ });
+
+ it("never goes negative", () => {
+ expect(formatCountdown(-5)).toBe("00:00");
+ });
+
+ it("handles non-finite input", () => {
+ expect(formatCountdown(Number.NaN)).toBe("00:00");
+ expect(formatCountdown(Number.POSITIVE_INFINITY)).toBe("00:00");
+ });
+});