mirror of
https://github.com/mauricioabh/arbpulse.git
synced 2026-07-28 08:07:43 +00:00
feat(pwa): installable PWA + responsive mobile dashboard
- Add Web App Manifest + ArbPulse icons (192/512/maskable/apple-touch) under web/public/; no service worker (real-time honesty: never serve stale data). - Add mobile/install metadata to index.html (theme-color, viewport-fit=cover, apple-mobile-web-app-*, manifest + apple-touch-icon links). - Responsive layout: PriceMatrix stacked per-venue cards below sm, StatsBar reflow, ~44px touch targets in Controls/ConfigPanel, safe-area top inset. - scripts/gen-icons.ps1 derives icon sizes from the master (no new deps). - openspec: add-pwa-mobile change (proposal/design/specs/tasks). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -37,12 +37,21 @@ function Toggle({
|
||||
type="button"
|
||||
onClick={() => onChange(!on)}
|
||||
className={clsx(
|
||||
"flex items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left text-sm transition",
|
||||
modified ? "border-accent/50 bg-accent/5" : "border-ink-600/50 bg-ink-700/30 hover:border-ink-500",
|
||||
"flex min-h-[44px] items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left text-sm transition",
|
||||
modified
|
||||
? "border-accent/50 bg-accent/5"
|
||||
: "border-ink-600/50 bg-ink-700/30 hover:border-ink-500",
|
||||
)}
|
||||
>
|
||||
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>{label}</span>
|
||||
<span className={clsx("relative h-5 w-9 rounded-full transition", on ? "bg-accent" : "bg-ink-500")}>
|
||||
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"relative h-5 w-9 rounded-full transition",
|
||||
on ? "bg-accent" : "bg-ink-500",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
|
||||
@@ -77,11 +86,15 @@ function SliderRow({
|
||||
<div
|
||||
className={clsx(
|
||||
"rounded-xl border px-3 py-2",
|
||||
modified ? "border-accent/50 bg-accent/5" : "border-ink-600/50 bg-ink-700/30",
|
||||
modified
|
||||
? "border-accent/50 bg-accent/5"
|
||||
: "border-ink-600/50 bg-ink-700/30",
|
||||
)}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between text-sm">
|
||||
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>{label}</span>
|
||||
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="font-mono text-accent">{valueLabel}</span>
|
||||
</div>
|
||||
<input
|
||||
@@ -179,7 +192,9 @@ export function ConfigPanel({ config }: Props): JSX.Element {
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-slate-500">Active exchanges</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-slate-500">
|
||||
Active exchanges
|
||||
</p>
|
||||
{EXCHANGES.map(({ id, label }) => (
|
||||
<Toggle
|
||||
key={id}
|
||||
|
||||
@@ -9,15 +9,28 @@ interface Props {
|
||||
config: PublicConfig;
|
||||
}
|
||||
|
||||
function Toggle({ label, on, onChange }: { label: string; on: boolean; onChange: (v: boolean) => void }): JSX.Element {
|
||||
function Toggle({
|
||||
label,
|
||||
on,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
on: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!on)}
|
||||
className="flex items-center justify-between gap-3 rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2 text-left text-sm transition hover:border-ink-500"
|
||||
className="flex min-h-[44px] items-center justify-between gap-3 rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2 text-left text-sm transition hover:border-ink-500"
|
||||
>
|
||||
<span className="text-slate-300">{label}</span>
|
||||
<span className={clsx("relative h-5 w-9 rounded-full transition", on ? "bg-accent" : "bg-ink-500")}>
|
||||
<span
|
||||
className={clsx(
|
||||
"relative h-5 w-9 rounded-full transition",
|
||||
on ? "bg-accent" : "bg-ink-500",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
|
||||
@@ -41,7 +54,7 @@ export function Controls({ stats, config }: Props): JSX.Element {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => control.resume()}
|
||||
className="rounded-xl border border-profit/40 bg-profit/10 px-3 py-2 text-sm font-semibold text-profit transition hover:bg-profit/20"
|
||||
className="min-h-[44px] rounded-xl border border-profit/40 bg-profit/10 px-3 py-2 text-sm font-semibold text-profit transition hover:bg-profit/20"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
@@ -49,7 +62,7 @@ export function Controls({ stats, config }: Props): JSX.Element {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => control.pause()}
|
||||
className="rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm font-semibold text-warn transition hover:bg-warn/20"
|
||||
className="min-h-[44px] rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm font-semibold text-warn transition hover:bg-warn/20"
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
@@ -57,13 +70,17 @@ export function Controls({ stats, config }: Props): JSX.Element {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => control.reset()}
|
||||
className="rounded-xl border border-loss/40 bg-loss/10 px-3 py-2 text-sm font-semibold text-loss transition hover:bg-loss/20"
|
||||
className="min-h-[44px] rounded-xl border border-loss/40 bg-loss/10 px-3 py-2 text-sm font-semibold text-loss transition hover:bg-loss/20"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Toggle label="Demo mode (synthetic feed)" on={stats.demoMode} onChange={(v) => control.setDemo(v)} />
|
||||
<Toggle
|
||||
label="Demo mode (synthetic feed)"
|
||||
on={stats.demoMode}
|
||||
onChange={(v) => control.setDemo(v)}
|
||||
/>
|
||||
|
||||
<div className="rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2">
|
||||
<div className="mb-1 flex items-center justify-between text-sm">
|
||||
@@ -89,7 +106,10 @@ export function Controls({ stats, config }: Props): JSX.Element {
|
||||
<div className="flex justify-between">
|
||||
<span>Taker fees</span>
|
||||
<span className="font-mono">
|
||||
K {pct(config.takerFees.kraken, 2)} · By {pct(config.takerFees.bybit, 2)} · O {pct(config.takerFees.okx, 2)} · Bn {pct(config.takerFees.binance, 2)}
|
||||
K {pct(config.takerFees.kraken, 2)} · By{" "}
|
||||
{pct(config.takerFees.bybit, 2)} · O{" "}
|
||||
{pct(config.takerFees.okx, 2)} · Bn{" "}
|
||||
{pct(config.takerFees.binance, 2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between">
|
||||
|
||||
@@ -50,7 +50,83 @@ export function PriceMatrix({ quotes }: Props): JSX.Element {
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl border border-ink-600/50">
|
||||
{/* Mobile: per-venue stacked cards (no horizontal overflow). */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:hidden">
|
||||
{quotes.map((q) => {
|
||||
const spread =
|
||||
q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
|
||||
return (
|
||||
<div
|
||||
key={q.exchange}
|
||||
className="rounded-xl border border-ink-600/50 bg-ink-700/30 p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2 font-display text-sm">
|
||||
<span
|
||||
className={clsx("h-2 w-2 rounded-full", statusDot(q.status))}
|
||||
/>
|
||||
{LABELS[q.exchange]}
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 font-mono text-sm tabular-nums">
|
||||
<div className="flex items-center justify-between">
|
||||
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||
Bid
|
||||
</dt>
|
||||
<dd
|
||||
className={clsx(
|
||||
q.bid !== null && q.bid === maxBid
|
||||
? "font-semibold text-profit"
|
||||
: "text-slate-300",
|
||||
)}
|
||||
>
|
||||
{q.bid !== null ? usd(q.bid) : "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||
Bid Qty
|
||||
</dt>
|
||||
<dd className="text-slate-500">
|
||||
{q.bidQty?.toFixed(3) ?? "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||
Ask
|
||||
</dt>
|
||||
<dd
|
||||
className={clsx(
|
||||
q.ask !== null && q.ask === minAsk
|
||||
? "font-semibold text-accent"
|
||||
: "text-slate-300",
|
||||
)}
|
||||
>
|
||||
{q.ask !== null ? usd(q.ask) : "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||
Ask Qty
|
||||
</dt>
|
||||
<dd className="text-slate-500">
|
||||
{q.askQty?.toFixed(3) ?? "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||
Spread
|
||||
</dt>
|
||||
<dd className="text-slate-400">
|
||||
{spread !== null ? usd(spread) : "—"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop / tablet: full table from the sm breakpoint up. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border border-ink-600/50 sm:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-ink-700/50 text-left text-xs uppercase tracking-wider text-slate-400">
|
||||
@@ -64,34 +140,50 @@ export function PriceMatrix({ quotes }: Props): JSX.Element {
|
||||
</thead>
|
||||
<tbody className="font-mono tabular-nums">
|
||||
{quotes.map((q) => {
|
||||
const spread = q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
|
||||
const spread =
|
||||
q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
|
||||
return (
|
||||
<tr key={q.exchange} className="border-t border-ink-600/40">
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="flex items-center gap-2 font-display text-sm">
|
||||
<span className={clsx("h-2 w-2 rounded-full", statusDot(q.status))} />
|
||||
<span
|
||||
className={clsx(
|
||||
"h-2 w-2 rounded-full",
|
||||
statusDot(q.status),
|
||||
)}
|
||||
/>
|
||||
{LABELS[q.exchange]}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className={clsx(
|
||||
"px-4 py-2.5 text-right",
|
||||
q.bid !== null && q.bid === maxBid ? "font-semibold text-profit" : "text-slate-300",
|
||||
q.bid !== null && q.bid === maxBid
|
||||
? "font-semibold text-profit"
|
||||
: "text-slate-300",
|
||||
)}
|
||||
>
|
||||
{q.bid !== null ? usd(q.bid) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-500">{q.bidQty?.toFixed(3) ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-500">
|
||||
{q.bidQty?.toFixed(3) ?? "—"}
|
||||
</td>
|
||||
<td
|
||||
className={clsx(
|
||||
"px-4 py-2.5 text-right",
|
||||
q.ask !== null && q.ask === minAsk ? "font-semibold text-accent" : "text-slate-300",
|
||||
q.ask !== null && q.ask === minAsk
|
||||
? "font-semibold text-accent"
|
||||
: "text-slate-300",
|
||||
)}
|
||||
>
|
||||
{q.ask !== null ? usd(q.ask) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-500">{q.askQty?.toFixed(3) ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-400">{spread !== null ? usd(spread) : "—"}</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-500">
|
||||
{q.askQty?.toFixed(3) ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-400">
|
||||
{spread !== null ? usd(spread) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -6,10 +6,20 @@ interface Props {
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: string; tone?: "profit" | "loss" | "warn" }): JSX.Element {
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "profit" | "loss" | "warn";
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] uppercase tracking-widest text-slate-500">{label}</span>
|
||||
<span className="text-[10px] uppercase tracking-widest text-slate-500">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"font-mono text-lg font-semibold tabular-nums",
|
||||
@@ -25,14 +35,23 @@ function Stat({ label, value, tone }: { label: string; value: string; tone?: "pr
|
||||
);
|
||||
}
|
||||
|
||||
function circuitTone(c: EngineStats["circuit"]): { label: string; cls: string } {
|
||||
function circuitTone(c: EngineStats["circuit"]): {
|
||||
label: string;
|
||||
cls: string;
|
||||
} {
|
||||
switch (c) {
|
||||
case "running":
|
||||
return { label: "RUNNING", cls: "bg-profit/15 text-profit border-profit/40" };
|
||||
return {
|
||||
label: "RUNNING",
|
||||
cls: "bg-profit/15 text-profit border-profit/40",
|
||||
};
|
||||
case "paused":
|
||||
return { label: "PAUSED", cls: "bg-warn/15 text-warn border-warn/40" };
|
||||
case "tripped":
|
||||
return { label: "BREAKER TRIPPED", cls: "bg-loss/15 text-loss border-loss/40" };
|
||||
return {
|
||||
label: "BREAKER TRIPPED",
|
||||
cls: "bg-loss/15 text-loss border-loss/40",
|
||||
};
|
||||
default: {
|
||||
const _exhaustive: never = c;
|
||||
return { label: _exhaustive, cls: "" };
|
||||
@@ -45,29 +64,45 @@ export function StatsBar({ stats, connected }: Props): JSX.Element {
|
||||
const pnlTone = stats.realizedPnl >= 0 ? "profit" : "loss";
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-8 gap-y-4 rounded-2xl border border-ink-600/60 bg-ink-800/60 px-5 py-4 backdrop-blur-sm">
|
||||
<Stat label="Realized P&L" value={`$${usd(stats.realizedPnl)}`} tone={pnlTone} />
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-4 rounded-2xl border border-ink-600/60 bg-ink-800/60 px-4 py-4 backdrop-blur-sm sm:gap-x-8 sm:px-5">
|
||||
<Stat
|
||||
label="Realized P&L"
|
||||
value={`$${usd(stats.realizedPnl)}`}
|
||||
tone={pnlTone}
|
||||
/>
|
||||
<Stat label="Trades" value={String(stats.tradesExecuted)} />
|
||||
<Stat label="Opportunities" value={String(stats.opportunitiesDetected)} />
|
||||
<Stat label="Rejected" value={String(stats.tradesRejected)} tone="warn" />
|
||||
<Stat label="Ticks" value={stats.ticksProcessed.toLocaleString()} />
|
||||
<Stat label="Engine /tick" value={`${stats.avgTickMs.toFixed(3)}ms`} />
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<div className="flex w-full flex-wrap items-center gap-2 sm:ml-auto sm:w-auto sm:gap-3">
|
||||
{stats.demoMode && (
|
||||
<span className="rounded-full border border-accent/40 bg-accent/15 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-accent">
|
||||
Demo / Simulated Feed
|
||||
</span>
|
||||
)}
|
||||
<span className={clsx("rounded-full border px-3 py-1 text-xs font-semibold tracking-wider", circuit.cls)}>
|
||||
<span
|
||||
className={clsx(
|
||||
"rounded-full border px-3 py-1 text-xs font-semibold tracking-wider",
|
||||
circuit.cls,
|
||||
)}
|
||||
>
|
||||
{circuit.label}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold tracking-wider",
|
||||
connected ? "border-profit/40 bg-profit/10 text-profit" : "border-loss/40 bg-loss/10 text-loss",
|
||||
connected
|
||||
? "border-profit/40 bg-profit/10 text-profit"
|
||||
: "border-loss/40 bg-loss/10 text-loss",
|
||||
)}
|
||||
>
|
||||
<span className={clsx("h-2 w-2 rounded-full", connected ? "bg-profit" : "bg-loss")} />
|
||||
<span
|
||||
className={clsx(
|
||||
"h-2 w-2 rounded-full",
|
||||
connected ? "bg-profit" : "bg-loss",
|
||||
)}
|
||||
/>
|
||||
{connected ? "LIVE" : "OFFLINE"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user