SessionGuard
Warn before a session expires, then show a sign-in dialog over the desk. The guard leaves the surrounding UI mounted, so drafts and layout can survive re-authentication.
import { useState } from "react"
import { SessionGuard } from "@/components/ui/session-guard"
export default function SessionGuardDemo() {
const [expiresAt, setExpiresAt] = useState(() => Date.now() + 30_000)
const [note, setNote] = useState("")
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => setExpiresAt(0)}>Expire session</button>
</div>
<div className="flex min-h-72 w-sm max-w-full flex-col justify-center gap-3 text-xs">
<SessionGuard expiresAt={expiresAt} warnMs={60_000} onReauthenticate={async () => {
setExpiresAt(Date.now() + 300_000)
return true
}}>
<p className="text-sm text-muted-foreground">This example signs in immediately.</p>
</SessionGuard>
<label className="flex flex-col gap-1.5">
Draft note
<input className="rounded border border-border bg-background px-3 py-2" value={note} onChange={(event) => setNote(event.target.value)} placeholder="Kept through sign-in" />
</label>
</div>
</>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/session-guard#v1.4.13
npx shadcn@latest add tradecn/ui/session-guard#v1.4.13
yarn dlx shadcn@latest add tradecn/ui/session-guard#v1.4.13
bunx --bun shadcn@latest add tradecn/ui/session-guard#v1.4.13
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Add the shadcn components it composes:
pnpm dlx shadcn@latest add button dialognpx shadcn@latest add button dialogyarn dlx shadcn@latest add button dialogbunx --bun shadcn@latest add button dialog -
Copy the files into your project:
components/ui/ session-guard.tsx import { cn } from "cn" import { useEffect, useMemo, useRef, useState, type ReactNode } from "react" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { useNow } from "@/hooks/use-clock" import type { Clock } from "@/lib/clock" import { NUMERIC_CLASS } from "@/lib/format" import { Countdown, formatRemaining } from "@/components/ui/countdown" // A session ends and the trader must not lose a half-typed ticket. The guard reads one number, when the // session expires, and does three things with it: in the warning window it shows a banner with a countdown // and a button that asks the consumer to extend the session; at expiry it opens a dialog that is a hotkey // wall, blocking every action underneath and unmounting nothing, so drafts and layouts stay where they are; // and it reports the phase for a status bar. The guard holds no token and knows no protocol: the consumer's // login is the consumer's, rendered as the dialog's children, and `onReauthenticate` is how it is asked. export type SessionPhase = "none" | "live" | "warning" | "expired" export interface SessionStatusValue { phase: SessionPhase /** Milliseconds until the session ends, negative past it, null with no session. */ remainingMs: number | null expiresAt: number | null } export interface SessionGuardLabels { /** The banner's sentence; `{remaining}` is where the countdown goes. */ warning: string /** The banner's button. */ extend: string /** The dialog. */ expiredTitle: string expiredDescription: string reauthenticate: string /** While `onReauthenticate` is out, and when it said no. */ pending: string failed: string /** The status readout's word, and its phases as words for a screen reader. */ session: string live: string ending: string ended: string noSession: string } export const DEFAULT_SESSION_GUARD_LABELS: SessionGuardLabels = { warning: "Your session ends in {remaining}.", extend: "Stay signed in", expiredTitle: "Your session has ended", expiredDescription: "Sign in again to carry on. Nothing on the desk has been lost.", reauthenticate: "Sign in again", pending: "Signing in…", failed: "That did not work. Try again.", session: "Session", live: "signed in", ending: "ending soon", ended: "ended", noSession: "no session", } /** The warning window: two minutes before the end. */ export const DEFAULT_WARN_MS = 120_000 /** The phase of a session at a moment: none without an end, expired at or past it, warning within `warnMs` of it, live before that. */ export function sessionStatus(expiresAt: number | null | undefined, now: number, warnMs: number = DEFAULT_WARN_MS): SessionStatusValue { if (expiresAt === null || expiresAt === undefined || !Number.isFinite(expiresAt)) return { phase: "none", remainingMs: null, expiresAt: null } const remainingMs = expiresAt - now return { phase: remainingMs <= 0 ? "expired" : remainingMs <= warnMs ? "warning" : "live", remainingMs, expiresAt } } export interface UseSessionStatusOptions { /** How long before the end the warning starts. Default two minutes. */ warnMs?: number /** The shared one-second clock unless given another. */ clock?: Clock } /** The session's phase and time left, ticking once a second on the shared clock. */ export function useSessionStatus(expiresAt: number | null | undefined, options: UseSessionStatusOptions = {}): SessionStatusValue { const now = useNow(options.clock) const warnMs = options.warnMs ?? DEFAULT_WARN_MS return useMemo(() => sessionStatus(expiresAt, now, warnMs), [expiresAt, now, warnMs]) } function phaseWord(phase: SessionPhase, labels: SessionGuardLabels): string { return phase === "live" ? labels.live : phase === "warning" ? labels.ending : phase === "expired" ? labels.ended : labels.noSession } /** Asks the consumer for a new session and reports where the ask stands. */ function useReauthenticate(onReauthenticate: () => Promise<boolean>, phase: SessionPhase) { const [pending, setPending] = useState(false) const [failed, setFailed] = useState(false) const latest = useRef(onReauthenticate) useEffect(() => { latest.current = onReauthenticate }) // A refusal is forgotten once the session is live again, settled during render. const [seen, setSeen] = useState(phase) if (seen !== phase) { setSeen(phase) if (phase === "live" || phase === "none") setFailed(false) } const attempt = async () => { if (pending) return setPending(true) setFailed(false) try { const ok = await latest.current() setFailed(!ok) } catch { setFailed(true) } finally { setPending(false) } } return { pending, failed, attempt } } export interface SessionGuardProps { /** When the session ends, ms since the epoch. Null or undefined while there is no session. */ expiresAt: number | null | undefined /** How long before the end the banner shows. Default two minutes. */ warnMs?: number /** Ask for a new session. Resolve true when there is one; the guard closes when `expiresAt` moves. */ onReauthenticate: () => Promise<boolean> /** The consumer's re-authentication, shown in the dialog above the button: a password field, a hardware token prompt, a note. */ children?: ReactNode /** The session ended. Called once per expiry. */ onExpire?: () => void labels?: Partial<SessionGuardLabels> /** The shared one-second clock unless given another. */ clock?: Clock /** Classes on the banner. */ className?: string } // The dialog is a wall: a close request is not a way through it. function keepOpen() { // The session is what opens and closes it. } export function SessionGuard({ expiresAt, warnMs = DEFAULT_WARN_MS, onReauthenticate, children, onExpire, labels: labelsProp, clock, className }: SessionGuardProps) { const labels = useMemo<SessionGuardLabels>(() => ({ ...DEFAULT_SESSION_GUARD_LABELS, ...labelsProp }), [labelsProp]) const status = useSessionStatus(expiresAt, { warnMs, clock }) const { pending, failed, attempt } = useReauthenticate(onReauthenticate, status.phase) const expire = useRef(onExpire) useEffect(() => { expire.current = onExpire }) useEffect(() => { if (status.phase === "expired") expire.current?.() }, [status.phase]) const [before, after] = labels.warning.split("{remaining}") return ( <div data-slot="tradecn-session-guard" data-session-phase={status.phase} className={cn(status.phase === "warning" ? "block" : "contents", className)}> {status.phase === "warning" && status.expiresAt !== null && ( <div role="status" data-session-banner="" className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-md border border-expiring/50 bg-expiring-soft px-3 py-1.5 text-xs text-foreground lining-nums tabular-nums"> <span> {before} <Countdown expiresAt={status.expiresAt} compact announce={false} thresholds={{ soonMs: warnMs }} label={labels.session} clock={clock} className="text-expiring" /> {after} </span> <Button type="button" size="sm" variant="outline" className="h-7" disabled={pending} data-session-extend="" data-pending={pending || undefined} onClick={() => void attempt()}> {pending ? labels.pending : labels.extend} </Button> {failed && ( <span role="alert" data-session-failed="" className="text-destructive"> {labels.failed} </span> )} </div> )} <Dialog open={status.phase === "expired"} onOpenChange={keepOpen}> <DialogContent showCloseButton={false} data-session-dialog="" className="sm:max-w-md"> <DialogHeader> <DialogTitle>{labels.expiredTitle}</DialogTitle> <DialogDescription>{labels.expiredDescription}</DialogDescription> </DialogHeader> {children} <div className="flex flex-wrap items-center gap-2"> <Button type="button" disabled={pending} data-session-reauthenticate="" data-pending={pending || undefined} onClick={() => void attempt()}> {pending ? labels.pending : labels.reauthenticate} </Button> {failed && ( <span role="alert" data-session-failed="" className="text-xs text-destructive"> {labels.failed} </span> )} </div> </DialogContent> </Dialog> </div> ) } export interface SessionStatusProps { expiresAt: number | null | undefined warnMs?: number clock?: Clock labels?: Partial<SessionGuardLabels> className?: string } /** The session for a status bar: the word, the time left, and the phase on `data-session-status` and in the accessible name. */ export function SessionStatus({ expiresAt, warnMs = DEFAULT_WARN_MS, clock, labels: labelsProp, className }: SessionStatusProps) { const labels = useMemo<SessionGuardLabels>(() => ({ ...DEFAULT_SESSION_GUARD_LABELS, ...labelsProp }), [labelsProp]) const status = useSessionStatus(expiresAt, { warnMs, clock }) const remaining = status.remainingMs !== null && status.remainingMs > 0 ? formatRemaining(status.remainingMs) : null const word = phaseWord(status.phase, labels) return ( <span data-session-status={status.phase} aria-label={`${labels.session}: ${word}${remaining === null ? "" : `, ${remaining}`}`} className={cn("inline-flex items-center gap-1 whitespace-nowrap", status.phase === "warning" && "text-expiring", status.phase === "expired" && "text-destructive", className)} > <span aria-hidden>{labels.session}</span>{" "} {status.phase === "expired" ? ( <span aria-hidden>{labels.ended}</span> ) : remaining !== null ? ( <span aria-hidden data-numeric="" className={NUMERIC_CLASS}> {remaining} </span> ) : ( <span aria-hidden className="text-muted-foreground"> {labels.noSession} </span> )} </span> ) }components/ui/ countdown.tsx import { cn } from "cn" import { useEffect, useLayoutEffect, useRef, useState } from "react" import { useNow } from "@/hooks/use-clock" import { sharedClock, type Clock } from "@/lib/clock" // Time left to a moment: digits that tick once a second and a bar that shrinks to nothing. // // The digits read the shared clock, so a stack of forty countdowns costs one interval. The bar is // one Web Animation on its element, started once and left to the compositor, so it costs nothing per // frame; under reduced motion it steps with the digits instead. The tier turns in the last seconds // and stops at zero. What the countdown never does is decide anything: it shows the time it was // given, and the server's own word is what says whether the thing it timed is over. export type CountdownTier = "plenty" | "soon" | "expired" export interface CountdownThresholds { /** From how far out the tier is `soon`. */ soonMs: number } /** A placeholder until the people who work the screen say how many seconds count as soon. */ export const PROVISIONAL_COUNTDOWN_THRESHOLDS: CountdownThresholds = { soonMs: 10_000 } /** Pure tiering. At or below zero is expired, whatever the thresholds. */ export function countdownTier(remainingMs: number, thresholds: CountdownThresholds = PROVISIONAL_COUNTDOWN_THRESHOLDS): CountdownTier { if (!(remainingMs > 0)) return "expired" return remainingMs <= thresholds.soonMs ? "soon" : "plenty" } /** "1:30", "0:09", "0:00", and "1:02:03" past an hour. Rounds up, so it never says zero while time is left. */ export function formatRemaining(ms: number): string { const total = Number.isFinite(ms) ? Math.max(0, Math.ceil(ms / 1000)) : 0 const h = Math.floor(total / 3600) const m = Math.floor((total % 3600) / 60) const s = total % 60 const minutes = h > 0 ? String(m).padStart(2, "0") : String(m) return `${h > 0 ? `${h}:` : ""}${minutes}:${String(s).padStart(2, "0")}` } const TIER_CLASS: Record<CountdownTier, string> = { plenty: "text-foreground", soon: "font-semibold text-expiring", expired: "text-muted-foreground", } const BAR_CLASS: Record<CountdownTier, string> = { plenty: "bg-primary", soon: "bg-expiring", expired: "bg-muted-foreground", } function prefersReducedMotion(): boolean { return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches } export interface CountdownProps { /** When it ends, in ms since the epoch. */ expiresAt: number /** When it began, for the bar's full width. Left out, the moment the countdown was first drawn. */ startsAt?: number thresholds?: CountdownThresholds clock?: Clock /** The name a screen reader gives it, and the subject of its announcements. Default "Time left". */ label?: string /** The digits alone, inline, for a grid cell. */ compact?: boolean /** Speak the time left when the tier changes, in a polite live region. Default true; turn it off in a grid, where the stack speaks. */ announce?: boolean /** Called once when the digits reach zero. A display event: the server's word says whether the thing timed is over. */ onExpire?: () => void className?: string } export function Countdown({ expiresAt, startsAt, thresholds = PROVISIONAL_COUNTDOWN_THRESHOLDS, clock, label = "Time left", compact = false, announce = true, onExpire, className }: CountdownProps) { const c = clock ?? sharedClock() const now = useNow(c) const remaining = expiresAt - now const tier = countdownTier(remaining, thresholds) // The bar's full width is `startsAt` to `expiresAt`; without `startsAt`, from first sight to the end. const [firstSeen] = useState(now) const total = Math.max(1, expiresAt - (startsAt ?? firstSeen)) const fraction = Math.max(0, Math.min(1, remaining / total)) const [reduced] = useState(prefersReducedMotion) // Drawn from the digits' tick when it cannot animate: reduced motion, or nothing left to animate. const staticBar = reduced || tier === "expired" const bar = useRef<HTMLSpanElement>(null) useLayoutEffect(() => { const el = bar.current if (!el || staticBar || typeof el.animate !== "function") return const left = expiresAt - c.now() if (left <= 0) return const from = Math.max(0, Math.min(1, left / total)) // One animation for exactly the time left, linear, held at zero when it ends. The compositor runs it. const animation = el.animate([{ transform: `scaleX(${from})` }, { transform: "scaleX(0)" }], { duration: left, easing: "linear", fill: "forwards" }) return () => animation.cancel() }, [expiresAt, total, c, staticBar]) // Said once, when the digits reach zero; said again only if the end moves out and comes back. const latest = useRef(onExpire) useEffect(() => { latest.current = onExpire }) const fired = useRef(false) useEffect(() => { if (tier !== "expired") { fired.current = false return } if (fired.current) return fired.current = true latest.current?.() }, [tier]) // Derived state, set during render: speak when the tier changes, never on a plain tick. const [seen, setSeen] = useState(tier) const [message, setMessage] = useState("") if (seen !== tier) { setSeen(tier) setMessage(`${label} ${formatRemaining(remaining)}`) } return ( <span role="timer" aria-label={label} data-slot="tradecn-countdown" data-tier={tier} className={cn(compact ? "inline-flex items-baseline" : "inline-flex min-w-16 flex-col gap-0.5 rounded px-1", "text-xs lining-nums tabular-nums", tier === "soon" && !compact && "bg-expiring-soft", TIER_CLASS[tier], className)} > <span data-countdown-digits data-numeric=""> {formatRemaining(remaining)} </span> {!compact && ( <span aria-hidden className="block h-1 w-full overflow-hidden rounded-full bg-muted"> <span ref={bar} className={cn("block h-full w-full origin-left", BAR_CLASS[tier])} style={staticBar ? { transform: `scaleX(${fraction})` } : undefined} /> </span> )} {announce && ( <span aria-live="polite" className="sr-only"> {message} </span> )} </span> ) }hooks/use-clock.ts import { useSyncExternalStore } from "react" import { sharedClock, type Clock } from "@/lib/clock" /** The clock's time. The caller re-renders on each tick and on nothing else. The shared one-second clock unless given another. */ export function useNow(clock?: Clock): number { const c = clock ?? sharedClock() return useSyncExternalStore(c.subscribe, c.now, c.now) }lib/clock.ts // A clock shared by everything that ticks: one interval, running only while someone listens. // // Feed ages and countdowns both want "now" once a second, and a screen with forty of them wants one // timer, not forty. `now()` is the time of the last tick and is stable between ticks, which is what // useSyncExternalStore needs from a snapshot: two reads with no tick between them agree. export interface Clock { subscribe(cb: () => void): () => void /** The time of the last tick; stable between ticks. */ now(): number } /** One interval shared by every subscriber; it runs only while someone is listening. */ export function createClock(intervalMs = 1000, source: () => number = Date.now): Clock { let current = source() let timer: ReturnType<typeof setInterval> | null = null const listeners = new Set<() => void>() return { now: () => current, subscribe(cb) { listeners.add(cb) if (timer === null) { current = source() timer = setInterval(() => { current = source() for (const l of listeners) l() }, intervalMs) } return () => { listeners.delete(cb) if (!listeners.size && timer !== null) { clearInterval(timer) timer = null } } }, } } let shared: Clock | null = null /** The one-second clock every tradecn item ticks on unless it is given another. Made on first use. */ export function sharedClock(): Clock { return (shared ??= createClock(1000)) }lib/format.ts // Number formatting for trading screens. Pure functions, no React, no dependencies. // Every formatter returns NULL_TOKEN for null, undefined, NaN, and infinities, so a grid // never prints "NaN" or "undefined" while a feed is warming up. export const NULL_TOKEN = "–" const MINUS = "−" export type Nullable = number | null | undefined export interface Locale { /** BCP 47 tag; defaults to en-US. */ locale?: string } export type PriceConvention = /** Fixed decimals, e.g. equities at 2. */ | { kind: "decimal"; decimals: number } /** Decimals derived from the instrument's tick size, e.g. 0.001 gives 3. */ | { kind: "tick"; tick: number } /** * Fractional quoting. `denominator` 32 or 64; `half` renders a half tick as "+" (99-16+) or "5" (99-165); * `eighths` renders eighths of a tick as a trailing digit (99-162 is 99 and 16 2/8 thirty-seconds). */ | { kind: "fraction"; denominator: 32 | 64; half: "+" | "5"; eighths?: boolean } /** What a quote is typed and read in. Bills quote on discount, most bonds on price, some on yield, credit often on a spread in basis points. */ export type QuoteBasis = "price" | "yield" | "discount" | "spread" export interface InstrumentConvention { price: PriceConvention /** Smallest price increment, used for stepping and rounding. */ tick: number yieldDecimals?: number quantityUnit?: "notional" | "contracts" /** Default price. */ quoteBasis?: QuoteBasis /** What a quote steps by in its basis. Default: the tick for price, 0.001 for yield and discount, 0.1 for spread. */ quoteStep?: number /** Decimals for a yield, discount, or spread quote. Default 3, 3, and 1. Price quotes follow their convention. */ quoteDecimals?: number } function isNil(v: Nullable): v is null | undefined { return v === null || v === undefined || !Number.isFinite(v) } // One Intl.NumberFormat per locale and option set; construction is the expensive part. const formatters = new Map<string, Intl.NumberFormat>() export function numberFormat(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat { const key = `${locale ?? "en-US"}|${JSON.stringify(options)}` let nf = formatters.get(key) if (!nf) { nf = new Intl.NumberFormat(locale ?? "en-US", options) formatters.set(key, nf) } return nf } function fixed(v: number, decimals: number, locale?: string, extra: Intl.NumberFormatOptions = {}): string { return numberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, ...extra }).format(v) } function typographicMinus(s: string): string { return s.replace(/-/g, MINUS) } /** Decimal places needed to show a price on this tick grid. 0.001 gives 3, 1/32 gives 5, 1 gives 0. Capped at `max`. */ export function decimalsFromTick(tick: number, max = 8): number { if (!Number.isFinite(tick) || tick <= 0) return 0 for (let d = 0; d <= max; d++) { const scaled = tick * 10 ** d if (Math.abs(scaled - Math.round(scaled)) < 1e-9 * Math.max(1, scaled)) return d } return max } /** Nearest price on the tick grid, cleaned of float noise. */ export function roundToTick(v: number, tick: number): number { if (!Number.isFinite(v) || !Number.isFinite(tick) || tick <= 0) return v return Number((Math.round(v / tick) * tick).toFixed(decimalsFromTick(tick))) } /** Move `steps` ticks (negative allowed) from the nearest grid price. */ export function stepByTick(v: number, tick: number, steps: number): number { return roundToTick(roundToTick(v, tick) + steps * tick, tick) } /** Whole-number and sub-tick decomposition on an integer grid so 32nds never drift. */ function decomposeFraction(v: number, c: Extract<PriceConvention, { kind: "fraction" }>) { const sub = c.eighths ? 8 : 2 const unitsPerWhole = c.denominator * sub const total = Math.round(Math.abs(v) * unitsPerWhole) const whole = Math.floor(total / unitsPerWhole) const rem = total - whole * unitsPerWhole const ticks = Math.floor(rem / sub) const subticks = rem - ticks * sub return { negative: v < 0 && total > 0, whole, ticks, subticks, sub } } /** 99.515625 as "99-16+" (32nds, half "+"), "99-165" (half "5"), or "99-164" with eighths. */ export function formatFraction(v: Nullable, c: Extract<PriceConvention, { kind: "fraction" }>): string { if (isNil(v)) return NULL_TOKEN const { negative, whole, ticks, subticks, sub } = decomposeFraction(v, c) let tail = "" if (subticks !== 0) { if (c.eighths) tail = String(subticks) else tail = c.half } void sub return `${negative ? MINUS : ""}${whole}-${String(ticks).padStart(2, "0")}${tail}` } export function formatPrice(v: Nullable, c: PriceConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN switch (c.kind) { case "decimal": return typographicMinus(fixed(v, c.decimals, l?.locale)) case "tick": return typographicMinus(fixed(roundToTick(v, c.tick), decimalsFromTick(c.tick), l?.locale)) case "fraction": return formatFraction(v, c) } } /** * Inverse of formatPrice for ticket entry. Accepts the convention's own notation and a plain decimal. * Returns null when the text is not a price. Fraction input: "99-16+", "99-165", "99-162", "99-16", "99". */ export function parsePrice(s: string, c: PriceConvention): number | null { const text = s.trim().replace(/−/g, "-").replace(/,/g, "") if (!text) return null if (c.kind === "fraction") { const m = /^([+-]?)(\d+)(?:-(\d{1,2})([+0-9])?)?$/.exec(text) if (m) { const [, sign, wholeText, ticksText, tail] = m const ticks = ticksText ? Number(ticksText) : 0 if (ticks >= c.denominator) return null let subFraction = 0 if (tail === "+") subFraction = 0.5 else if (tail !== undefined) { const digit = Number(tail) if (c.eighths) { if (digit > 7) return null subFraction = digit / 8 } else if (c.half === "5" && digit === 5) subFraction = 0.5 else if (digit === 0) subFraction = 0 else return null } const value = Number(wholeText) + (ticks + subFraction) / c.denominator return sign === "-" ? -value : value } } if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null if (c.kind === "tick") return roundToTick(n, c.tick) if (c.kind === "decimal") return Number(n.toFixed(c.decimals)) return n } /** 4.2531 as "4.253%". */ export function formatYield(v: Nullable, o: { decimals?: number; suffix?: "%" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + (o.suffix ?? "%") } /** 12.5 as "12.5 bp"; signed gives "+12.5 bp". */ export function formatBps(v: Nullable, o: { decimals?: number; signed?: boolean; unit?: "bp" | "bps" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const unit = o.unit ?? "bp" const body = o.signed ? formatSigned(v, { decimals: o.decimals ?? 1, locale: o.locale }) : typographicMinus(fixed(v, o.decimals ?? 1, o.locale)) return unit ? `${body} ${unit}` : body } /** Dollar value of a basis point as whole currency; compact gives "$1.23K". */ export function formatDv01(v: Nullable, o: { currency?: string; compact?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const currency = o.currency ?? "USD" if (o.compact) { const symbol = numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }) .formatToParts(0) .find((p) => p.type === "currency")?.value ?? "" return `${v < 0 ? MINUS : ""}${symbol}${formatNotional(Math.abs(v), { compact: true, locale: o.locale })}` } return typographicMinus(numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }).format(v)) } /** 1250000 as "1.25M" (compact), "1.25mm" (unit "mm", the desk's word for millions, never scaled further), or "1,250,000.00". Compact suffixes K, M, B, T. */ export function formatNotional(v: Nullable, o: { compact?: boolean; decimals?: number; unit?: "mm" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 if (o.unit === "mm") return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(v / 1e6)) + "mm" if (!o.compact) return typographicMinus(fixed(v, decimals, o.locale)) const abs = Math.abs(v) const [divisor, suffix] = abs >= 1e12 ? [1e12, "T"] : abs >= 1e9 ? [1e9, "B"] : abs >= 1e6 ? [1e6, "M"] : abs >= 1e3 ? [1e3, "K"] : [1, ""] return typographicMinus(fixed(v / divisor, suffix ? decimals : 0, o.locale)) + suffix } /** "+0.12", "−0.12" (U+2212), and "0.00" for zero: flat carries no sign. */ export function formatSigned(v: Nullable, o: { decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 2, o.locale, { signDisplay: "exceptZero" })) } /** 1.234 as "1.23%"; signed gives "+1.23%". */ export function formatPercent(v: Nullable, o: { decimals?: number; signed?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 return (o.signed ? formatSigned(v, { decimals, locale: o.locale }) : typographicMinus(fixed(v, decimals, o.locale))) + "%" } /** Whole quantities with grouping: 1000000 as "1,000,000". */ export function formatQuantity(v: Nullable, l?: Locale): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(Math.round(v), 0, l?.locale)) } /** * A coupon the way a run prints it: 4.125 as "4 1/8", 4.5 as "4 1/2", 4 as "4", 0 as "0" (coupons step in eighths). * One off the eighths grid prints as a plain decimal, "4.1". `style: "decimal"` prints "4.125%". */ export function formatCoupon(v: Nullable, o: { style?: "fraction" | "decimal"; decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN if (o.style === "decimal") return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + "%" const eighths = Math.round(Math.abs(v) * 8) if (Math.abs(eighths / 8 - Math.abs(v)) > 1e-9) return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: o.decimals ?? 3 }).format(v)) const whole = Math.floor(eighths / 8) const rem = eighths % 8 const fraction = rem === 0 ? "" : rem % 4 === 0 ? "1/2" : rem % 2 === 0 ? `${rem / 2}/4` : `${rem}/8` const body = fraction ? (whole ? `${whole} ${fraction}` : fraction) : String(whole) return `${v < 0 && eighths > 0 ? MINUS : ""}${body}` } /** A date as an instant, a millisecond count, or a string `Date` can read. A date-only string ("2034-05-15") is that day in UTC. */ export type DateLike = Date | number | string function toDate(d: DateLike | null | undefined): Date | null { if (d === null || d === undefined) return null const date = d instanceof Date ? d : new Date(d) return Number.isNaN(date.getTime()) ? null : date } const dateFormatters = new Map<string, Intl.DateTimeFormat>() /** * A maturity as a run prints it: "05/15/34", or "05/15/2034" with `year: "numeric"`. Read in UTC, so a * date-only string prints as that day wherever the screen is. */ export function formatMaturity(d: DateLike | null | undefined, o: { year?: "2-digit" | "numeric" } & Locale = {}): string { const date = toDate(d) if (!date) return NULL_TOKEN const key = `${o.locale ?? "en-US"}|${o.year ?? "2-digit"}` let f = dateFormatters.get(key) if (!f) { f = new Intl.DateTimeFormat(o.locale ?? "en-US", { month: "2-digit", day: "2-digit", year: o.year ?? "2-digit", timeZone: "UTC" }) dateFormatters.set(key, f) } return f.format(date) } /** Whole UTC days from `now` (the moment, by default) to a maturity. Negative once it has passed; null when the date does not read. */ export function daysToMaturity(d: DateLike | null | undefined, now: DateLike = Date.now()): number | null { const maturity = toDate(d) const from = toDate(now) if (!maturity || !from) return null const day = (x: Date) => Date.UTC(x.getUTCFullYear(), x.getUTCMonth(), x.getUTCDate()) return Math.round((day(maturity) - day(from)) / 86_400_000) } /** How many ticks `a` is from `b`, signed, to the nearest eighth of a tick: 99-17 against 99-16+ on a 1/64 tick is 1. NaN when a side is not a number or the tick is not positive. */ export function ticksBetween(a: number, b: number, tick: number): number { if (!Number.isFinite(a) || !Number.isFinite(b) || !(tick > 0)) return NaN return Math.round(((a - b) / tick) * 8) / 8 } /** A count of ticks: "+1", "−0.5", "0", up to three decimals and no trailing zeros. Signed unless told otherwise; `unit` adds a word. */ export function formatTicks(v: Nullable, o: { signed?: boolean; unit?: string } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const body = typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: 3, signDisplay: o.signed === false ? "auto" : "exceptZero" }).format(v)) return o.unit ? `${body} ${o.unit}` : body } /** * The class a number wears (contract rule 14): lining, tabular figures whatever the font, in the numeric family. * `--tradecn-font-numeric` is the sans by default, so letters stay proportional and only the digits take one * width, which is what a column of prices wants. docs/typography.md has the reasoning and the tokens. */ export const NUMERIC_CLASS = "font-(family-name:--tradecn-font-numeric) lining-nums tabular-nums" /** The same figures in the mono stack, `--tradecn-font-mono`: for a fraction quote, whose dash, ticks, and tail must line up down a column. */ export const MONO_NUMERIC_CLASS = "font-(family-name:--tradecn-font-mono) lining-nums tabular-nums" /** * The class for a number printed under a convention: a fraction price (99-16+) sets in the mono stack, so * 99-16+ over 99-17 keeps its dash and its tail in the same place; every other price, and every quote in * another basis, keeps the numeric family. */ export function numericFontClass(c?: PriceConvention | InstrumentConvention | null): string { const price = c && "price" in c ? (quoteBasisOf(c) === "price" ? c.price : null) : c return price?.kind === "fraction" ? MONO_NUMERIC_CLASS : NUMERIC_CLASS } /** The word for a quote field's label, per basis. */ export const QUOTE_BASIS_LABELS: Record<QuoteBasis, string> = { price: "Price", yield: "Yield", discount: "Discount", spread: "Spread" } const QUOTE_DEFAULTS: Record<Exclude<QuoteBasis, "price">, { step: number; decimals: number }> = { yield: { step: 0.001, decimals: 3 }, discount: { step: 0.001, decimals: 3 }, spread: { step: 0.1, decimals: 1 }, } export function quoteBasisOf(c: InstrumentConvention): QuoteBasis { return c.quoteBasis ?? "price" } /** What a quote steps by in its basis: the tick for a price, else `quoteStep` or the basis default. */ export function quoteStepOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? c.tick : (c.quoteStep ?? QUOTE_DEFAULTS[basis].step) } function quoteDecimalsOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? 0 : (c.quoteDecimals ?? QUOTE_DEFAULTS[basis].decimals) } /** A quote in the instrument's basis: a price by its convention, else a fixed decimal with the basis's decimals and no unit, since the field's label says the basis. */ export function formatQuote(v: Nullable, c: InstrumentConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN if (quoteBasisOf(c) === "price") return formatPrice(v, c.price, l) return typographicMinus(fixed(roundToTick(v, quoteStepOf(c)), quoteDecimalsOf(c), l?.locale)) } /** Inverse of formatQuote for a quote field. A price takes its notation or a decimal; the others take a decimal and snap to the quote step. Null when the text is not a quote. */ export function parseQuote(s: string, c: InstrumentConvention): number | null { if (quoteBasisOf(c) === "price") return parsePrice(s, c.price) const text = s.trim().replace(/−/g, "-").replace(/,/g, "") if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) return Number.isFinite(n) ? roundToTick(n, quoteStepOf(c)) : null } /** Move a quote by `steps` of its step, from the nearest grid value. */ export function stepQuote(v: number, c: InstrumentConvention, steps: number): number { return stepByTick(v, quoteStepOf(c), steps) } /** Bind a convention once per instrument; a grid column calls `formatters[row.instrumentId].price(v)`, a quote field its `quote`, `parseQuote`, and `stepQuote`. */ export function createInstrumentFormatter(c: InstrumentConvention, l?: Locale) { return { tick: c.tick, basis: quoteBasisOf(c), quoteStep: quoteStepOf(c), price: (v: Nullable) => formatPrice(v, c.price, l), yield: (v: Nullable) => formatYield(v, { decimals: c.yieldDecimals ?? 3, locale: l?.locale }), step: (v: number, steps: number) => stepByTick(v, c.tick, steps), parsePrice: (s: string) => parsePrice(s, c.price), quantity: (v: Nullable) => formatQuantity(v, l), quote: (v: Nullable) => formatQuote(v, c, l), parseQuote: (s: string) => parseQuote(s, c), stepQuote: (v: number, steps: number) => stepQuote(v, c, steps), } } -
Add the tokens to your stylesheet:
:root { --expiring: oklch(0.53 0.2 350); --expiring-soft: oklch(0.53 0.2 350 / 18%); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; } .dark { --expiring: oklch(0.74 0.17 350); --expiring-soft: oklch(0.74 0.17 350 / 18%); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; } -
Append this to your stylesheet:
:root[data-accessibility="hyperlegible"], [data-accessibility="hyperlegible"] { --tradecn-font-sans: var(--tradecn-font-accessible); --tradecn-font-mono: var(--tradecn-font-accessible-mono); }
Usage
import { useState } from "react"
import { SessionGuard } from "@/components/ui/session-guard"
function GuardedDraft() {
const [expiresAt, setExpiresAt] = useState(() => Date.now() + 30_000)
const [note, setNote] = useState("")
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => setExpiresAt(0)}>Expire session</button>
</div>
<div className="flex min-h-72 w-sm max-w-full flex-col justify-center gap-3 text-xs">
<SessionGuard expiresAt={expiresAt} warnMs={60_000} onReauthenticate={async () => {
setExpiresAt(Date.now() + 300_000)
return true
}}>
<p className="text-sm text-muted-foreground">This example signs in immediately.</p>
</SessionGuard>
<label className="flex flex-col gap-1.5">
Draft note
<input className="rounded border border-border bg-background px-3 py-2" value={note} onChange={(event) => setNote(event.target.value)} placeholder="Kept through sign-in" />
</label>
</div>
</>
)
}
expiresAt is milliseconds since the epoch. This example starts with thirty seconds left, inside its one-minute warning window. Type a draft, then choose Expire session or wait for the deadline. The sign-in dialog leaves the draft mounted. Stay signed in or Sign in again renews immediately for five minutes, clearing the banner or dialog.
The callback simulates success. In your application, await your session service, update the expiry in application state on success, and return its boolean result. Returning true alone does not close the guard. Put your sign-in UI in its children and keep the desk outside it.
Pending and refused sign-ins
Choose Show expired session, then Sign in again. The request stays pending until you choose Accept sign-in or Refuse sign-in inside the dialog. These two controls stand in for the identity provider; keeping them inside the modal lets you retry after refusal.
Acceptance updates the expiry and closes the dialog. Refusal leaves it open with the guard's error message; choose Sign in again to retry. This variant uses warnMs={0} to skip the warning banner and keep every request inside the dialog, where its reply controls are available. The example settles an unfinished request when it unmounts and creates no timer for the simulated reply.
import { useEffect, useRef, useState } from "react"
import { SessionGuard, SessionStatus } from "@/components/ui/session-guard"
export default function SessionGuardRepliesDemo() {
const [expiresAt, setExpiresAt] = useState<number | null>(null)
const [waiting, setWaiting] = useState(false)
const reply = useRef<((ok: boolean) => void) | null>(null)
useEffect(() => () => {
reply.current?.(false)
reply.current = null
}, [])
function answer(ok: boolean) {
const resolve = reply.current
if (!resolve) return
reply.current = null
if (ok) setExpiresAt(Date.now() + 300_000)
setWaiting(false)
resolve(ok)
}
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => setExpiresAt(0)}>Show expired session</button>
</div>
<div className="flex min-h-96 w-fit max-w-full items-center justify-center text-xs">
<SessionStatus expiresAt={expiresAt} warnMs={0} />
<SessionGuard expiresAt={expiresAt} warnMs={0} onReauthenticate={() => new Promise<boolean>((resolve) => {
reply.current = resolve
setWaiting(true)
})}>
<fieldset className="space-y-2 text-xs" disabled={!waiting}>
<legend className="mb-2">Simulated identity provider</legend>
<p>Choose Sign in again, then send its reply.</p>
<div className="flex flex-wrap gap-2">
<button type="button" className="rounded border border-border px-2 py-1 disabled:opacity-50" onClick={() => answer(true)}>Accept sign-in</button>
<button type="button" className="rounded border border-border px-2 py-1 disabled:opacity-50" onClick={() => answer(false)}>Refuse sign-in</button>
</div>
</fieldset>
</SessionGuard>
</div>
</>
)
}
Session status readouts
SessionStatus can show the same expiry elsewhere on the desk, independently of the guard. This comparison uses one fixed clock to keep all four phases available to inspect. Real sessions use the shared ticking clock by default.
To place a readout in StatusBar, install that component separately and pass the readout to one of its slots.
import type { Clock } from "@/lib/clock"
import { SessionStatus } from "@/components/ui/session-guard"
const NOW = Date.UTC(2026, 8, 23, 12)
const clock: Clock = { now: () => NOW, subscribe: () => () => {} }
const sessions = [
{ name: "Live", expiresAt: NOW + 300_000 },
{ name: "Warning", expiresAt: NOW + 30_000 },
{ name: "Expired", expiresAt: NOW },
{ name: "No session", expiresAt: null },
]
export default function SessionGuardStatusDemo() {
return (
<table className="w-fit max-w-full text-xs lining-nums tabular-nums">
<caption className="caption-bottom pt-3 text-muted-foreground">Fixed clock for comparison</caption>
<thead><tr><th scope="col" className="pb-2 pr-6 text-left font-normal">Phase</th><th scope="col" className="pb-2 text-left font-normal">Readout</th></tr></thead>
<tbody>{sessions.map(({ name, expiresAt }) => (
<tr key={name} className="border-t border-border">
<th scope="row" className="py-2 pr-6 text-left font-normal">{name}</th>
<td className="py-2"><SessionStatus expiresAt={expiresAt} clock={clock} /></td>
</tr>
))}</tbody>
</table>
)
}
API Reference
The guard reads the expiry time you supply. Your application owns the session and renders its sign-in UI in the dialog's children.
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
expiresAt |
number | null | undefined |
Required | Expiry time in epoch milliseconds. Null, undefined, or a non-finite number means no session. |
warnMs |
number |
120_000 |
Warning window in milliseconds; exported as DEFAULT_WARN_MS. |
onReauthenticate |
() => Promise<boolean> |
Required | Request a new session; resolve true on success or false on refusal. |
children |
ReactNode |
None | Sign-in UI above the dialog's button. |
onExpire |
() => void |
None | Called by an effect on entering the expired phase, including an already-expired mount. |
labels |
Partial<SessionGuardLabels> |
DEFAULT_SESSION_GUARD_LABELS |
Override the words listed below. |
clock |
Clock |
sharedClock() |
Clock for the phase and countdown; the default ticks once a second. |
className |
string |
None | Classes on the root wrapper, which uses block during warning and contents otherwise. |
Changing an expired timestamp to another expired timestamp, or replacing onExpire while still expired, does not trigger another expiry notification. Leaving the expired phase and entering it again does. The callback follows a React effect's lifetime; it is not an exactly-once notification across remounts or development Strict Mode effect replay.
Phases
The root carries data-session-phase. The pure helper and hook both return SessionStatusValue:
| API | Inputs | Result |
|---|---|---|
sessionStatus(expiresAt, now, warnMs?) |
expiresAt: number | null | undefined; now: number in epoch milliseconds; warnMs: number = DEFAULT_WARN_MS |
The status at the supplied time. |
useSessionStatus(expiresAt, options?) |
The same expiry type; options: UseSessionStatusOptions = {} |
Status updated on clock ticks. Options are warnMs?: number and clock?: Clock, with the same defaults as the guard. |
| Field | Type | Meaning |
|---|---|---|
phase |
SessionPhase |
"none", "live", "warning", or "expired". |
remainingMs |
number | null |
Expiry minus the clock time; negative after expiry, null with no session. |
expiresAt |
number | null |
The supplied finite expiry, or null with no session. |
| Phase | When | What the guard shows |
|---|---|---|
none |
Null, undefined, NaN, or infinite expiry |
Nothing |
live |
More than warnMs left, above zero |
Nothing |
warning |
warnMs or less left, above zero |
The banner |
expired |
Zero or less left | The dialog |
warnMs is compared directly; zero or negative values skip the warning phase. The hook uses the shared one-second clock from countdown unless you supply another. For tests, pass createClock(1000, () => t) and advance both t and the timer.
The banner
The banner is a role="status" strip with a compact Countdown, the warning sentence, and the extension button. It uses expiring and expiring-soft; the countdown uses warnMs as its soon threshold and disables its own announcements inside the status region.
The wall
The consumer's modal Dialog is open whenever the phase is expired. It has no close button and ignores close requests from Escape or a click outside. A future expiry closes it: one inside the warning window shows the banner, while one beyond that window shows neither. Clearing the expiry or passing a non-finite value also closes it by moving to none.
The modal blocks pointer interaction underneath. For events inside its role="dialog", the use-hotkeys dispatcher only considers scopes declared inside it. The guard does not unmount the surrounding desk; keeping drafts and stores alive during sign-in remains part of your application's lifecycle.
The dialog contains the title, description, children, and re-authentication button. The banner and dialog share pending and failure state.
Re-authentication
Both buttons call onReauthenticate. While its promise is pending, the visible button is disabled, has data-pending="true", and reads Signing in…. Further button presses are ignored until it settles. A phase change does not cancel the request or reset pending state.
Resolving true clears failure state but does not change the expiry or phase. Resolving false, throwing, or rejecting sets failure state and shows That did not work. Try again. as a role="alert" beside the button. The promise result alone never closes the banner or dialog.
Starting another attempt clears the failure. Entering live or none also clears it; moving between warning and expired preserves it. A request that fails after the phase has moved to live or none can set failure again, hidden until a banner or dialog next appears.
The status readout
SessionStatus displays Session followed by the time left (1:59:30 or 0:45), ended at expiry, or no session without a finite expiry. Remaining time rounds up to the next second. It exposes the phase through data-session-status and words in its accessible name, such as Session: ending soon, 1:30. Warning uses expiring; expiry uses destructive.
| Prop | Type | Default | Purpose |
|---|---|---|---|
expiresAt |
number | null | undefined |
Required | Expiry time in epoch milliseconds; the same no-session values as the guard. |
warnMs |
number |
120_000 |
Warning window in milliseconds. |
clock |
Clock |
sharedClock() |
Clock for the phase and remaining time. |
labels |
Partial<SessionGuardLabels> |
DEFAULT_SESSION_GUARD_LABELS |
Readout text and accessible phase names. |
className |
string |
None | Classes on the readout's span. |
Labels
Both components merge partial overrides into DEFAULT_SESSION_GUARD_LABELS. Every label is a string. Use one {remaining} placeholder in warning: the countdown goes between the first two parts of that split.
| Label | Default | Where |
|---|---|---|
warning |
Your session ends in {remaining}. |
Banner sentence |
extend |
Stay signed in |
Banner button |
expiredTitle / expiredDescription |
Your session has ended / Sign in again to carry on. Nothing on the desk has been lost. |
Dialog heading and description |
reauthenticate |
Sign in again |
Dialog button |
pending |
Signing in… |
Either button while pending |
failed |
That did not work. Try again. |
Alert beside either button |
session |
Session |
Readout prefix and countdown's accessible name |
live / ending |
signed in / ending soon |
Readout's accessible phase names |
ended / noSession |
ended / no session |
Visible and accessible readout text |
What it does not do
The guard holds no token, reads no cookie, and does not refresh credentials or redirect. It displays the time you give it; your session service owns authentication and server authorization. It does not pause background work or enforce request permissions. Venue trading hours belong to the separate session-calendar module.
Tokens
The install adds expiring and expiring-soft if you do not have them, for the banner and the readout's warning phase.
Built on shadcn's button and dialog.
This page is docs/session-guard.md at v1.4.13.