Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Footer } from "@/components/layout/Footer";
import { WalletProvider } from "@/lib/contexts/WalletContext";
import "./globals.css";
import { RealtimeProvider } from "@/lib/realtime/RealtimeProvider";
import { HealthProvider } from "@/lib/health/HealthProvider";
import { InboxProvider } from "@/lib/notifications/InboxProvider";
import { PwaRegistrar } from "@/components/pwa/pwa-registrar";
import { DebugMount } from "@/components/debug/debug-mount";
Expand Down
4 changes: 2 additions & 2 deletions app/markets/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ const MarketCard = ({ href, category, onChainMarketId, reasoningTraces, expiry,
<div className="space-y-4">
<div>
<div className="flex justify-between text-label-caps font-label-caps mb-2">
<span className="text-secondary">YES {yesProb}%<SourceTag source={fromChain ? "chain" : "backend"} /></span>
<span className="text-secondary">YES {yesProb}%<SourceTag source={fromChain ? "chain" : "backend"} ledger={view?.ledger} readAt={fromChain ? chain.dataUpdatedAt : undefined} /></span>
<span className="text-tertiary">NO {noProb}%</span>
</div>
<div className="w-full h-2 rounded-full bg-outline-variant overflow-hidden flex">
Expand All @@ -166,7 +166,7 @@ const MarketCard = ({ href, category, onChainMarketId, reasoningTraces, expiry,
</div>
<div className="grid grid-cols-2 gap-4 border-t border-outline-variant/30 pt-4">
<div>
<p className="text-label-caps text-on-surface-variant uppercase mb-1">Liquidity<SourceTag source={fromChain ? "chain" : "backend"} /></p>
<p className="text-label-caps text-on-surface-variant uppercase mb-1">Liquidity<SourceTag source={fromChain ? "chain" : "backend"} ledger={view?.ledger} readAt={fromChain ? chain.dataUpdatedAt : undefined} /></p>
<p className="font-data-mono text-body-md">{liquidity}</p>
</div>
<div>
Expand Down
16 changes: 12 additions & 4 deletions app/markets/quick-view/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,14 @@ function MarketQuickViewContent() {
setBuyStatus("This market closes too soon to safely place a trade.");
return;
}
if (health.rpc.status === "down") {
setBuyStatus("Stellar RPC isn't responding; the trade can't be simulated.");
return;
}
if (now !== null && view && isStale(chain.dataUpdatedAt, now, MARKET_STALE_AFTER_SEC)) {
setBuyStatus("Refreshing the price before you trade…");
await chain.refetch();
}
setBuyStatus(null);
try {
const result = await buy.mutateAsync({
Expand Down Expand Up @@ -374,7 +382,7 @@ function MarketQuickViewContent() {
<div className="flex items-center gap-2">
<span className={`${isOpen ? 'bg-secondary-container text-on-secondary-container' : 'bg-surface-container-highest text-on-surface-variant'} font-label-caps text-[10px] px-2 py-0.5 rounded-full font-bold`}>
{statusLabel}
{chainStatus && <SourceTag source="chain" />}
{chainStatus && <SourceTag source="chain" ledger={view?.ledger} readAt={chain.dataUpdatedAt} />}
</span>
{countdown && <span className="text-[10px] font-data-mono text-on-surface-variant">{countdown}</span>}
<span className="font-label-caps text-label-caps text-outline uppercase font-bold tracking-wider">{categoryLabel}</span>
Expand Down Expand Up @@ -403,7 +411,7 @@ function MarketQuickViewContent() {
<div className="space-y-1">
<div className="flex items-baseline gap-2">
<span className="font-display-lg text-display-lg text-on-surface text-4xl">{yesProb}%</span>
<SourceTag source={fromChain ? "chain" : "backend"} />
<SourceTag source={fromChain ? "chain" : "backend"} ledger={view?.ledger} readAt={fromChain ? chain.dataUpdatedAt : undefined} />
</div>
<p className="font-label-caps text-label-caps text-outline uppercase font-bold tracking-wider">YES price</p>
{view?.ledger != null && (
Expand Down Expand Up @@ -571,7 +579,7 @@ function MarketQuickViewContent() {
{position.data && (position.data.yes > 0n || position.data.no > 0n || positionState.data?.provisional || positionState.data?.awaitingRpc) && (
<p className="text-[11px] font-data-mono text-on-surface">
Your position: {formatUsdc(position.data.yes, { maxDecimals: 2 })} YES · {formatUsdc(position.data.no, { maxDecimals: 2 })} NO
<PendingValue state={positionState.data} fallback={<SourceTag source="chain" />} />
<PendingValue state={positionState.data} fallback={<SourceTag source="chain" ledger={view?.ledger} readAt={chain.dataUpdatedAt} />} />
</p>
)}

Expand Down Expand Up @@ -658,7 +666,7 @@ function MarketQuickViewContent() {
{/* Footer Action */}
<div className="px-6 py-4 bg-surface-container-low border-t border-outline-variant flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex flex-col items-center sm:items-start">
<span className="font-data-mono text-data-mono text-on-surface font-bold">{liquidityText}<SourceTag source={fromChain ? "chain" : "backend"} /></span>
<span className="font-data-mono text-data-mono text-on-surface font-bold">{liquidityText}<SourceTag source={fromChain ? "chain" : "backend"} ledger={view?.ledger} readAt={fromChain ? chain.dataUpdatedAt : undefined} /></span>
<span className="text-[10px] text-outline font-label-caps uppercase font-bold tracking-wider">Collateral locked</span>
</div>
<div className="flex items-center gap-3 w-full sm:w-auto">
Expand Down
114 changes: 114 additions & 0 deletions components/layout/HealthIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { useHealth, type OverallHealth } from "@/lib/health/HealthProvider";
import type { SocketStatus } from "@/lib/realtime/RealtimeProvider";

const OVERALL_LABEL: Record<OverallHealth, string> = {
live: "Live",
degraded: "Degraded",
offline: "Offline",
checking: "Checking…",
};

const OVERALL_DOT: Record<OverallHealth, string> = {
live: "bg-secondary",
degraded: "bg-tertiary",
offline: "bg-error",
checking: "bg-outline",
};

const SOCKET_LABEL: Record<SocketStatus, string> = {
connected: "connected",
reconnecting: "reconnecting",
offline: "offline",
unknown: "checking…",
};

function ago(seconds: number | null): string {
if (seconds === null) return "—";
if (seconds < 60) return `${seconds}s ago`;
return `${Math.floor(seconds / 60)}m ago`;
}

/**
* Honest status for the three data paths. Replaces the decorative pulsing dot:
* the label is text, not colour-only, and the popover lists each probe with a
* manual retry. Polling itself lives in the health monitor.
*/
export function HealthIndicator() {
const health = useHealth();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
const onClick = (event: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("keydown", onKey);
document.addEventListener("mousedown", onClick);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("mousedown", onClick);
};
}, [open]);

const rows = [
{ name: "Socket", value: SOCKET_LABEL[health.socket] },
{
name: "Stellar RPC",
value:
health.rpc.status === "ok"
? `ok · ledger ${health.rpc.latestLedger ?? "—"} (${ago(health.rpc.ledgerAgeSec)})`
: `${health.rpc.status}${health.rpc.latestLedger !== null ? ` · ledger ${health.rpc.latestLedger}` : ""}`,
},
{ name: "Backend API", value: health.backend.status },
];

return (
<div className="relative" ref={rootRef}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={`Connection status: ${OVERALL_LABEL[health.overall]}`}
className="flex items-center gap-1.5 px-2 py-1 rounded border border-outline-variant hover:bg-surface-container transition-colors"
>
<span aria-hidden="true" className={`w-2 h-2 rounded-full ${OVERALL_DOT[health.overall]}`} />
<span className="font-label-caps text-label-caps text-on-surface-variant">{OVERALL_LABEL[health.overall]}</span>
</button>
{open && (
<div
role="dialog"
aria-label="Data freshness and connection health"
className="absolute right-0 top-full mt-2 w-72 bg-white border border-outline-variant rounded-lg shadow-xl p-4 z-50"
>
<p className="font-label-caps text-label-caps text-on-surface-variant uppercase mb-3">Data sources</p>
<ul className="space-y-2 mb-3">
{rows.map((row) => (
<li key={row.name} className="flex justify-between gap-3 text-body-md">
<span className="text-on-surface-variant">{row.name}</span>
<span className="font-data-mono text-on-surface text-right">{row.value}</span>
</li>
))}
</ul>
<p className="text-[11px] text-on-surface-variant mb-3">
Health polling pauses while this tab is hidden and backs off when a probe fails.
</p>
<button
type="button"
onClick={() => health.retry()}
className="w-full py-2 bg-primary text-primary-foreground rounded font-label-caps text-label-caps hover:opacity-90"
>
Retry now
</button>
</div>
)}
</div>
);
}
2 changes: 2 additions & 0 deletions components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useInbox } from "@/lib/notifications/InboxProvider";
import WalletDropdown from "./WalletDropdown";
import { HealthIndicator } from "./HealthIndicator";

const Navbar = () => {
const pathname = usePathname();
Expand Down Expand Up @@ -96,6 +97,7 @@ const Navbar = () => {
</nav>
</div>
<div className="flex items-center gap-4">
<HealthIndicator />
<Link
href="/notifications"
aria-label={unread ? `Notifications, ${unread} unread` : "Notifications"}
Expand Down
57 changes: 57 additions & 0 deletions components/ui/source-tag.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"use client";

import { isStale } from "@/lib/stellar/health";
import { useNow } from "@/lib/health/useTicker";

export interface SourceTagProps {
/** "chain" values are read from the contract; "backend" are estimates. */
source: "chain" | "backend";
/** Ledger the value was read at, when known. */
ledger?: number | null;
/** Client wall-clock ms when the value was read (e.g. React Query dataUpdatedAt). */
readAt?: number;
/** Age past which a chain value is flagged stale. Default 60 s. */
staleAfterSec?: number;
}

function ageLabel(seconds: number): string {
if (seconds < 60) return `${seconds} s ago`;
if (seconds < 3_600) return `${Math.floor(seconds / 60)} m ago`;
return `${Math.floor(seconds / 3_600)} h ago`;
}

/**
* The single source-of-truth pill used everywhere a value can come from the
* chain or the backend. It carries the ledger and age in its accessible text
* and switches to a "stale" variant once the value is too old to trust.
*/
export function SourceTag({ source, ledger = null, readAt, staleAfterSec = 60 }: SourceTagProps) {
const now = useNow();
const hasRead = typeof readAt === "number" && readAt > 0;
const ageSec = hasRead ? Math.max(0, Math.round((now - (readAt as number)) / 1_000)) : null;
const stale = hasRead && isStale(readAt, now, staleAfterSec);
const chain = source === "chain";

const detail = [
ledger !== null ? `at ledger ${ledger}` : null,
ageSec !== null ? ageLabel(ageSec) : null,
]
.filter(Boolean)
.join(", ");
const title = chain
? `Read live from the market contract${detail ? `, ${detail}` : ""}`
: "OracleDesk backend estimate; may lag the chain";

const palette = chain
? stale
? "bg-error-container text-on-error-container"
: "bg-secondary-container text-on-secondary-container"
: "bg-surface-container-highest text-on-surface-variant";

return (
<span title={title} className={`ml-1 text-[9px] font-bold uppercase px-1 rounded ${palette}`}>
{stale ? `${source} · stale` : source}
{detail && <span className="sr-only">{`, ${detail}`}</span>}
</span>
);
}
72 changes: 72 additions & 0 deletions lib/health/HealthProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"use client";

import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { createHealthMonitor, type BackendHealth, type HealthSample, type RpcHealth } from "@/lib/stellar/health";
import { useRealtimeStatus, type SocketStatus } from "@/lib/realtime/RealtimeProvider";

/** One word for the navbar, derived from the three probes. */
export type OverallHealth = "live" | "degraded" | "offline" | "checking";

export interface HealthState {
socket: SocketStatus;
rpc: RpcHealth;
backend: BackendHealth;
overall: OverallHealth;
/** Force an immediate re-probe of both paths. */
retry: () => void;
}

const initialSample: HealthSample = {
rpc: { status: "down", latestLedger: null, ledgerAgeSec: null, checkedAt: 0 },
backend: { status: "down", checkedAt: 0 },
};

const HealthContext = createContext<HealthState | null>(null);

/**
* Mounts the health monitor once and combines it with the realtime socket
* state. Initial render is deterministic ("checking") on both server and
* client, so statically prerendered pages hydrate without a warning.
*/
export function HealthProvider({ children }: { children: ReactNode }) {
const [sample, setSample] = useState<HealthSample>(initialSample);
const [monitor] = useState(() => createHealthMonitor());
const socket = useRealtimeStatus();

useEffect(() => {
const unsubscribe = monitor.subscribe(setSample);
monitor.start();
return () => {
unsubscribe();
monitor.stop();
};
}, [monitor]);

const checked = sample.rpc.checkedAt > 0 || sample.backend.checkedAt > 0;
const overall = useMemo<OverallHealth>(() => {
if (!checked) return "checking";
if (sample.rpc.status === "down" && sample.backend.status === "down" && socket === "offline") return "offline";
if (
sample.rpc.status !== "ok" ||
sample.backend.status !== "ok" ||
socket === "offline" ||
socket === "reconnecting"
) {
return "degraded";
}
return "live";
}, [sample, socket, checked]);

const value = useMemo<HealthState>(
() => ({ socket, rpc: sample.rpc, backend: sample.backend, overall, retry: () => monitor.refresh() }),
[socket, sample, overall, monitor],
);

return <HealthContext.Provider value={value}>{children}</HealthContext.Provider>;
}

export function useHealth(): HealthState {
const ctx = useContext(HealthContext);
if (!ctx) throw new Error("HealthProvider is missing");
return ctx;
}
40 changes: 40 additions & 0 deletions lib/health/useTicker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"use client";

import { useSyncExternalStore } from "react";

/**
* One shared 5-second interval for every mounted freshness tag, so relative
* times update without giving each tag its own timer. Returns 0 during server
* render and hydration (deterministic), then the client clock.
*/
const TICK_MS = 5_000;

const subscribers = new Set<() => void>();
let timer: ReturnType<typeof setInterval> | null = null;
let current = Date.now();

function subscribe(callback: () => void) {
subscribers.add(callback);
if (timer === null) {
current = Date.now();
timer = setInterval(() => {
current = Date.now();
for (const notify of subscribers) notify();
}, TICK_MS);
}
return () => {
subscribers.delete(callback);
if (subscribers.size === 0 && timer !== null) {
clearInterval(timer);
timer = null;
}
};
}

const getSnapshot = () => current;
const getServerSnapshot = () => 0;

/** Current time in ms, ticking at most once per 5 s. 0 while prerendering. */
export function useNow(): number {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
Loading