diff --git a/README.md b/README.md index 0bc95f6..bd2862d 100644 --- a/README.md +++ b/README.md @@ -136,9 +136,18 @@ signed when the keys are present and unsigned when they are not. That is why there is no Yahoo-style fallback on this path — the primary source degrades to itself rather than to a second vendor with different provenance. -In the web dashboard this is the **Crypto** tab: a live grid of the majors, a -name-or-symbol picker, and an opt-in 30s auto-refresh that only ticks while that -tab is actually on screen. Prices are fetched when the tab is first opened +In the web dashboard this is the **Crypto** tab: a live grid of the majors with +24h/7d sparklines, a name-or-symbol picker, and an opt-in 30s auto-refresh that +only ticks while that tab is actually on screen. + +The sparklines have their own endpoint rather than reusing `/crypto/bars`: +twelve cards do not need thousands of OHLCV objects to draw twelve lines a +couple of hundred pixels wide, and Alpaca'''s multi-symbol bars endpoint +paginates, so one grid load is several upstream requests. The series is +downsampled server-side (24 points for 24h, 56 for 7d) and cached as a unit, so +a whole grid costs one set of requests per minute rather than one per visitor — +14KB on the wire for all twelve. A pair without enough history is drawn without +a line rather than as a flat one, and the summary says how many those were. Prices are fetched when the tab is first opened rather than on boot, so a visitor who never looks at it costs no upstream calls. ### Pages vs JSON @@ -207,6 +216,7 @@ as a bug. | `GET /crypto/quote?symbol=BTC/USD` | latest trade/quote with spread and spread in basis points | | `GET /crypto/bars?symbol=&timeframe=&start=&end=&limit=` | historical OHLCV (`1Min`…`1Week`) | | `GET /crypto/orderbook?symbol=&depth=` | top of book, both sides | +| `GET /crypto/sparklines?symbols=&period=24h|7d` | compact close-price series for the grid cards | | `GET /crypto/technicals?symbol=&horizon=1\|2` | locally computed indicators + technical score | | `GET /crypto/report?symbol=` | snapshot + technicals + score in one call | | `GET /crypto/` | the same report by path, e.g. `/crypto/BTC-USD` | diff --git a/public/app.js b/public/app.js index 2972233..1283b27 100644 --- a/public/app.js +++ b/public/app.js @@ -1493,6 +1493,17 @@ const CRYPTO_GRID_PAIRS = [ ]; const CRYPTO_REFRESH_MS = 30_000; + +/** Sparkline window shown on the cards. Persisted so it survives a reload. */ +let cryptoSparkPeriod = (() => { + try { + const saved = localStorage.getItem("cx-spark-period"); + return saved === "7d" || saved === "24h" ? saved : "24h"; + } catch { + // Private mode and blocked storage both throw; the default is fine. + return "24h"; + } +})(); let cryptoTimer = null; let cryptoLoading = false; @@ -1510,7 +1521,33 @@ function fmtPrice(n) { return "$" + Number(n).toLocaleString(undefined, { minimumFractionDigits: dp, maximumFractionDigits: dp }); } -function cryptoCard(s) { +/** + * Inline SVG sparkline for a card. Returns "" for fewer than two points rather + * than drawing a flat line, which would imply a price we never observed. + * + * viewBox coordinates with preserveAspectRatio="none" so one path stretches to + * whatever width the card ends up — no measuring, no redraw on resize. + */ +function cryptoSparkSvg(points, rising) { + if (!Array.isArray(points) || points.length < 2) return ""; + const w = 100; + const h = 28; + const min = Math.min(...points); + const max = Math.max(...points); + // A perfectly flat series has no range to scale against; draw it mid-height. + const span = max - min || 1; + const x = (i) => (i / (points.length - 1)) * w; + const y = (v) => h - 1 - ((v - min) / span) * (h - 2); + const line = points.map((v, i) => `${i ? "L" : "M"}${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(""); + const stroke = rising ? "var(--pos)" : "var(--neg)"; + return ``; +} + +function cryptoCard(s, spark) { const chg = s.change; const dir = chg == null ? "" : chg.percent >= 0 ? "positive" : "negative"; const price = s.latestTrade?.price ?? s.dailyBar?.close; @@ -1518,6 +1555,10 @@ function cryptoCard(s) { const spreadBps = q && q.askPrice && q.bidPrice ? ((q.askPrice - q.bidPrice) / ((q.askPrice + q.bidPrice) / 2)) * 10000 : null; + // The sparkline is coloured by its OWN period's direction, which is not + // always the session's: a pair can be down today inside a rising week, and + // painting the 7d line red because the day was red would misreport it. + const sparkRising = spark?.changePercent == null ? chg == null || chg.percent >= 0 : spark.changePercent >= 0; // An anchor, not a button: this has to be shareable, middle-clickable and // crawlable. The destination renders server-side, so it works before this // script has run at all. @@ -1528,9 +1569,13 @@ function cryptoCard(s) { ${esc(s.name || "")}
${fmtPrice(price)}
+ ${cryptoSparkSvg(spark?.points, sparkRising)}
${chg == null ? '' : `${chg.percent >= 0 ? "+" : ""}${chg.percent.toFixed(2)}%`} + ${spark?.changePercent != null + ? `${spark.changePercent >= 0 ? "+" : ""}${spark.changePercent.toFixed(1)}%` + : ""} ${spreadBps != null ? `${spreadBps.toFixed(1)} bps` : ""}
`; @@ -1544,15 +1589,28 @@ async function loadCryptoGrid() { const summary = $("#cx-summary"); if (!grid) { cryptoLoading = false; return; } if (!grid.dataset.loaded) grid.innerHTML = `
`; + const symbols = encodeURIComponent(CRYPTO_GRID_PAIRS.join(",")); try { - const d = await api(`/crypto/snapshot?symbols=${encodeURIComponent(CRYPTO_GRID_PAIRS.join(","))}`); + // Prices are the point of the grid; the sparklines are decoration on top. + // Fetched together, but the chart request is allowed to fail on its own — + // losing the lines is not a reason to lose the prices. + const [d, sparkRes] = await Promise.all([ + api(`/crypto/snapshot?symbols=${symbols}`), + api(`/crypto/sparklines?symbols=${symbols}&period=${cryptoSparkPeriod}`).catch(() => null), + ]); + const series = sparkRes?.series ?? {}; const rows = (d.snapshots || []).filter((s) => s.latestTrade || s.dailyBar); grid.innerHTML = rows.length - ? rows.map(cryptoCard).join("") + ? rows.map((s) => cryptoCard(s, series[s.symbol])).join("") : `

No crypto prices available right now.

`; grid.dataset.loaded = "1"; if (summary) { - summary.textContent = `${rows.length} pairs · Alpaca US crypto venue · updated ${new Date().toLocaleTimeString()}`; + const charted = rows.filter((s) => series[s.symbol]).length; + summary.textContent = + `${rows.length} pairs · Alpaca US crypto venue · updated ${new Date().toLocaleTimeString()}` + + // Say when the lines are missing rather than leaving bare cards that + // read as a rendering bug. + (charted === rows.length ? "" : ` · ${rows.length - charted} without ${cryptoSparkPeriod} history`); } } catch (e) { // Never blank an already-painted grid on a refresh failure — a transient @@ -1591,3 +1649,28 @@ attachLookup( $("#cx-refresh")?.addEventListener("click", loadCryptoGrid); $("#cx-auto")?.addEventListener("change", (e) => setCryptoAuto(e.target.checked)); + +/** Reflect the active sparkline window in the toggle. */ +function paintCryptoPeriod() { + $$("#cx-period-group button, .cx-period button").forEach((b) => + b.classList.toggle("on", b.dataset.period === cryptoSparkPeriod), + ); +} + +$(".cx-period")?.addEventListener("click", (e) => { + const b = e.target.closest("[data-period]"); + if (!b || b.dataset.period === cryptoSparkPeriod) return; + cryptoSparkPeriod = b.dataset.period; + try { + localStorage.setItem("cx-spark-period", cryptoSparkPeriod); + } catch { + /* not worth failing the interaction over */ + } + paintCryptoPeriod(); + // Force a repaint even though prices have not changed: the cached grid was + // drawn for the other window. + const grid = $("#cx-grid"); + if (grid) delete grid.dataset.loaded; + loadCryptoGrid(); +}); +paintCryptoPeriod(); diff --git a/public/index.html b/public/index.html index b307dea..7915402 100644 --- a/public/index.html +++ b/public/index.html @@ -112,6 +112,10 @@ +
+ + +
diff --git a/public/styles.css b/public/styles.css index 342b01c..c4d73a0 100644 --- a/public/styles.css +++ b/public/styles.css @@ -513,6 +513,25 @@ details.evidence .ev { font-size: 12.5px; color: var(--dim); border-left: 2px so .cx-quote { font-family: var(--mono); font-size: 11px; color: var(--dim); } .cx-name { color: var(--dim); font-size: 11.5px; margin-left: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; } .cx-price { font-family: var(--mono); font-size: 21px; font-weight: 800; margin: 8px 0 4px; letter-spacing: -.01em; } + +/* Sparkline. Stretches to the card's width via preserveAspectRatio="none"; + the stroke stays 1.5px because of vector-effect, so the horizontal scaling + does not smear the line. */ +.cx-spark { display: block; width: 100%; height: 28px; margin: 2px 0 6px; overflow: visible; } +.cx-sparkchg { font-size: 11px; opacity: .85; } +.cx-sparkchg.pos { color: var(--pos); } +.cx-sparkchg.neg { color: var(--neg); } + +/* 24h / 7d toggle */ +.cx-period { display: inline-flex; gap: 2px; padding: 3px; border: 1px solid var(--line); border-radius: 999px; background: var(--panel); } +.cx-period button { + background: transparent; border: 0; color: var(--dim); cursor: pointer; + font: inherit; font-size: 13px; font-weight: 600; + padding: 5px 12px; border-radius: 999px; transition: background .15s, color .15s; +} +.cx-period button:hover { color: var(--text); } +.cx-period button.on { background: var(--accent); color: var(--bg); } +.cx-period button:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 1px; } .cx-sub { display: flex; align-items: center; gap: 8px; font-family: var(--mono); font-size: 12px; } .cx-spread { color: var(--dim); font-size: 11px; margin-left: auto; } .cx-dim { color: var(--dim); } diff --git a/src/crypto/routes.ts b/src/crypto/routes.ts index be03140..6525e22 100644 --- a/src/crypto/routes.ts +++ b/src/crypto/routes.ts @@ -25,6 +25,7 @@ import type { BarTimeframe, IndicatorConfig, MarketBar } from "../types.ts"; import type { AlpacaCryptoClient } from "./client.ts"; import { analyzeCrypto } from "./analysis.ts"; import { computePerformance } from "./performance.ts"; +import { SPARK_PERIODS, SparklineService, isSparkPeriod } from "./sparkline.ts"; import type { CryptoFundamentalsClient } from "./fundamentals.ts"; import { renderCryptoIndexPage, renderCryptoPage, renderMissingCryptoPage } from "./page.ts"; import { SUPPORTED_PAIRS, getPair, lookupPairs, normalizePair, normalizePairs } from "./pairs.ts"; @@ -68,6 +69,7 @@ const RESERVED = new Set([ "orderbooks", "technicals", "report", + "sparklines", ]); export interface CryptoRouteDeps { @@ -77,6 +79,8 @@ export interface CryptoRouteDeps { appUrl: string; /** Market cap / supply. Optional: the pages render without it. */ fundamentals?: CryptoFundamentalsClient; + /** Compact price series for the grid cards. Optional. */ + sparklines?: SparklineService; } /** @@ -120,6 +124,8 @@ export async function handleCryptoRoute( return await orderbook(url, deps); case "technicals": return await technicals(url, deps); + case "sparklines": + return await sparklineRoute(url, deps); case "report": return await report(url.searchParams.get("symbol"), url, deps); } @@ -222,6 +228,8 @@ function index(deps: CryptoRouteDeps): Response { "GET /crypto/bars?symbol=&timeframe=&start=&end=&limit=": `historical OHLCV; timeframe one of ${VALID_TIMEFRAMES.join(", ")}`, "GET /crypto/orderbook?symbol=&depth=": "top of book, both sides", + "GET /crypto/sparklines?symbols=&period=24h|7d": + "compact close-price series for drawing sparklines", "GET /crypto/technicals?symbol=&horizon=1|2": "locally computed SMA/EMA/RSI/MACD/Bollinger/ATR + technical score", "GET /crypto/report?symbol=": "snapshot + technicals + score in one call", @@ -485,6 +493,44 @@ async function orderbook(url: URL, deps: CryptoRouteDeps): Promise { ); } +/** + * Compact close-price series, one per pair, for the grid cards. + * + * Deliberately not a variant of /crypto/bars: that returns full OHLCV and + * paginates, and twelve cards do not need thousands of bar objects to draw + * twelve lines a couple of hundred pixels wide. + */ +async function sparklineRoute(url: URL, deps: CryptoRouteDeps): Promise { + if (!deps.sparklines) return json({ error: "sparklines unavailable" }, 503); + const b = basket(url); + if ("error" in b) return b.error; + + const requested = url.searchParams.get("period") ?? "24h"; + if (!isSparkPeriod(requested)) { + return json({ error: `invalid period "${requested}"`, valid: SPARK_PERIODS }, 400); + } + + const series = await deps.sparklines.get(b.pairs, requested); + return json( + { + period: requested, + // Only pairs with a drawable line appear. A card omits its chart rather + // than drawing a flat line from a single observation. + series: Object.fromEntries( + b.pairs.flatMap((symbol) => { + const s = series.get(symbol); + return s ? [[symbol, s] as const] : []; + }), + ), + ...rejectedNote(b.rejected), + disclaimer: CRYPTO_DISCLAIMER, + }, + 200, + // Matches the service's own cache, so an edge hit and a process hit agree. + requested === "24h" ? 60 : 300, + ); +} + async function technicals(url: URL, deps: CryptoRouteDeps): Promise { const s = single(url.searchParams.get("symbol")); if ("error" in s) return s.error; diff --git a/src/crypto/sparkline.ts b/src/crypto/sparkline.ts new file mode 100644 index 0000000..a8d7bb3 --- /dev/null +++ b/src/crypto/sparkline.ts @@ -0,0 +1,141 @@ +/** + * Compact price series for the grid cards. + * + * The grid draws twelve sparklines at once. Sending raw bars for that would be + * ~2,000 objects of OHLCV where the card needs a shape — so the series is built + * and downsampled here, and the wire format is a bare array of closes. + * + * Upstream cost is the other half of the reason: Alpaca's multi-symbol bars + * endpoint paginates, so one grid load is several requests. That is fine once a + * minute for everyone; it is not fine per visitor, hence the cache. + */ +import type { AlpacaCryptoClient } from "./client.ts"; +import type { MarketBar } from "../types.ts"; + +export type SparkPeriod = "24h" | "7d"; + +export const SPARK_PERIODS: SparkPeriod[] = ["24h", "7d"]; + +interface PeriodSpec { + hours: number; + /** Most points to send per pair. A card is ~170px wide; more is invisible. */ + maxPoints: number; + /** How long a built series is reused. */ + cacheTtlMs: number; +} + +const SPECS: Record = { + "24h": { hours: 24, maxPoints: 24, cacheTtlMs: 60_000 }, + "7d": { hours: 24 * 7, maxPoints: 56, cacheTtlMs: 5 * 60_000 }, +}; + +export interface SparkSeries { + symbol: string; + /** Closing prices, oldest first. */ + points: number[]; + first: number | null; + last: number | null; + changePercent: number | null; + start: string | null; + end: string | null; +} + +/** + * Keep at most `max` points, evenly spaced, always retaining the first and + * last. Dropping the last point would move the line's endpoint away from the + * current price and make the card disagree with the number printed beside it. + */ +export function downsample(values: number[], max: number): number[] { + if (max <= 0) return []; + if (values.length <= max) return [...values]; + if (max === 1) return [values.at(-1)!]; + const step = (values.length - 1) / (max - 1); + const out: number[] = []; + for (let i = 0; i < max; i++) out.push(values[Math.round(i * step)]!); + return out; +} + +/** Bars for one symbol -> the series a card draws. */ +export function toSeries(symbol: string, bars: MarketBar[], maxPoints: number): SparkSeries { + const usable = bars.filter((b) => Number.isFinite(b.close)); + const points = downsample(usable.map((b) => b.close), maxPoints); + const first = points[0] ?? null; + const last = points.at(-1) ?? null; + return { + symbol, + points, + first, + last, + // Measured across the window actually returned, not the window requested — + // a pair with only six hours of history reports its six-hour change. + changePercent: first != null && last != null && first !== 0 ? ((last - first) / first) * 100 : null, + start: usable[0]?.timestamp ?? null, + end: usable.at(-1)?.timestamp ?? null, + }; +} + +export interface SparklineOptions { + now?: () => number; +} + +export class SparklineService { + private readonly now: () => number; + private cache = new Map }>(); + private inFlight = new Map>>(); + + constructor( + private readonly client: AlpacaCryptoClient, + options: SparklineOptions = {}, + ) { + this.now = options.now ?? Date.now; + } + + /** + * Series for `symbols`. The upstream fetch always covers the full requested + * set for the period and is cached as a unit, so two visitors looking at the + * same grid cost one set of requests, not two. + */ + async get(symbols: string[], period: SparkPeriod): Promise> { + const spec = SPECS[period]; + const cached = this.cache.get(period); + if (cached && this.now() - cached.at < spec.cacheTtlMs) return cached.series; + + const existing = this.inFlight.get(period); + if (existing) return existing; + + const task = (async () => { + const start = new Date(this.now() - spec.hours * 3_600_000).toISOString(); + const bars = await this.client.getBars({ + symbols, + timeframe: "1Hour", + start, + end: new Date(this.now()).toISOString(), + }); + const bySymbol = new Map(); + for (const b of bars) (bySymbol.get(b.symbol) ?? bySymbol.set(b.symbol, []).get(b.symbol)!).push(b); + + const series = new Map(); + for (const symbol of symbols) { + const rows = bySymbol.get(symbol) ?? []; + // A pair with one point cannot be drawn as a line; send it empty so the + // card omits the chart rather than rendering a flat line that implies + // a stable price we did not observe. + if (rows.length < 2) continue; + series.set(symbol, toSeries(symbol, rows, spec.maxPoints)); + } + this.cache.set(period, { at: this.now(), series }); + return series; + })(); + + this.inFlight.set(period, task); + try { + return await task; + } finally { + this.inFlight.delete(period); + } + } +} + +export function isSparkPeriod(v: unknown): v is SparkPeriod { + return v === "24h" || v === "7d"; +} diff --git a/src/registry.ts b/src/registry.ts index b939002..4decb2a 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -5,6 +5,7 @@ import type { AppConfig } from "./config.ts"; import { AlpacaClient } from "./providers/alpaca.ts"; import { AlpacaCryptoClient } from "./crypto/client.ts"; import { CryptoFundamentalsClient } from "./crypto/fundamentals.ts"; +import { SparklineService } from "./crypto/sparkline.ts"; import { YahooMarketDataClient } from "./providers/yahoo.ts"; import { FallbackMarketDataClient } from "./providers/market-fallback.ts"; import { SecFundamentalsProvider } from "./providers/sec.ts"; @@ -54,6 +55,10 @@ export function buildRegistry(config: AppConfig) { // the page degrades to "—" rather than failing when it is unreachable. const cryptoFundamentals = new CryptoFundamentalsClient(); + // Grid sparklines. Cached as a unit so twelve cards cost one set of + // paginated upstream requests per minute, not one set per visitor. + const cryptoSparklines = new SparklineService(crypto); + // News is registered separately from `transcripts` rather than folded into // it: it requires tickers to be meaningful and can spend metered search // credits, so it runs only when explicitly asked for (`transcripts news`). @@ -66,6 +71,7 @@ export function buildRegistry(config: AppConfig) { alpaca: market, crypto, cryptoFundamentals, + cryptoSparklines, marketSource: hasAlpaca ? "alpaca (yahoo fallback)" : "yahoo", fundamentals: new SecFundamentalsProvider(config), transcripts: buildTranscriptProviders(config), diff --git a/src/server.ts b/src/server.ts index 9be650f..6b07143 100644 --- a/src/server.ts +++ b/src/server.ts @@ -820,6 +820,7 @@ const server = Bun.serve({ indicators: INDICATOR_CONFIG, appUrl: config.appUrl, fundamentals: registry.cryptoFundamentals, + sparklines: registry.cryptoSparklines, }); if (cryptoResponse) return cryptoResponse; diff --git a/test/crypto-sparkline.test.ts b/test/crypto-sparkline.test.ts new file mode 100644 index 0000000..559877e --- /dev/null +++ b/test/crypto-sparkline.test.ts @@ -0,0 +1,135 @@ +/** + * Grid sparklines. + * + * The downsampler is the part worth guarding: it must never drop the last + * point, because the line's endpoint sits directly beside the current price on + * the card, and a line ending somewhere else reads as the card contradicting + * itself. The service is the other part — twelve cards must cost one set of + * upstream requests, not twelve. + */ +import { describe, expect, test } from "bun:test"; +import { SparklineService, downsample, toSeries } from "../src/crypto/sparkline.ts"; +import type { AlpacaCryptoClient } from "../src/crypto/client.ts"; +import type { MarketBar } from "../src/types.ts"; + +const NOW = Date.parse("2026-08-08T00:00:00Z"); + +function bars(symbol: string, closes: number[]): MarketBar[] { + return closes.map((close, i) => ({ + symbol, + timestamp: new Date(NOW - (closes.length - 1 - i) * 3_600_000).toISOString(), + open: close, high: close + 1, low: close - 1, close, + volume: 1, timeframe: "1Hour" as const, adjustment: "raw" as const, + })); +} + +describe("downsample", () => { + test("keeps a short series untouched", () => { + expect(downsample([1, 2, 3], 24)).toEqual([1, 2, 3]); + }); + + test("always keeps the first and last point", () => { + // The last point sits beside the printed price; losing it makes the card + // disagree with itself. + const values = Array.from({ length: 168 }, (_, i) => i); + const out = downsample(values, 56); + expect(out).toHaveLength(56); + expect(out[0]).toBe(0); + expect(out.at(-1)).toBe(167); + }); + + test("samples evenly across the window", () => { + const out = downsample([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 5); + expect(out).toEqual([0, 2, 5, 7, 9]); + }); + + test("degenerate limits do not throw", () => { + expect(downsample([1, 2, 3], 1)).toEqual([3]); + expect(downsample([1, 2, 3], 0)).toEqual([]); + expect(downsample([], 10)).toEqual([]); + }); +}); + +describe("toSeries", () => { + test("reports the change across the window it actually has", () => { + const s = toSeries("BTC/USD", bars("BTC/USD", [100, 110]), 24); + expect(s.changePercent).toBeCloseTo(10, 6); + expect(s.first).toBe(100); + expect(s.last).toBe(110); + expect(s.start).toBeTruthy(); + expect(s.end).toBeTruthy(); + }); + + test("a falling window is negative", () => { + expect(toSeries("X", bars("X", [200, 100]), 24).changePercent).toBeCloseTo(-50, 6); + }); + + test("non-finite closes are dropped rather than poisoning the line", () => { + const rows = bars("X", [100, 0, 110]); + (rows[1] as any).close = NaN; + const s = toSeries("X", rows, 24); + expect(s.points).toEqual([100, 110]); + expect(s.points.every(Number.isFinite)).toBe(true); + }); + + test("a zero opening price cannot produce Infinity", () => { + const s = toSeries("X", bars("X", [0, 50]), 24); + expect(s.changePercent).toBeNull(); + }); +}); + +describe("service", () => { + const client = (calls: any[] = []) => + ({ + getBars: async (req: any) => { + calls.push(req); + return [...bars("BTC/USD", [100, 105, 110]), ...bars("ETH/USD", [50, 49, 48])]; + }, + }) as unknown as AlpacaCryptoClient; + + test("returns a series per requested pair", async () => { + const svc = new SparklineService(client(), { now: () => NOW }); + const out = await svc.get(["BTC/USD", "ETH/USD"], "24h"); + expect(out.get("BTC/USD")!.points).toEqual([100, 105, 110]); + expect(out.get("ETH/USD")!.changePercent).toBeCloseTo(-4, 6); + }); + + test("requests hourly bars over the period's window", async () => { + const calls: any[] = []; + await new SparklineService(client(calls), { now: () => NOW }).get(["BTC/USD"], "7d"); + expect(calls[0].timeframe).toBe("1Hour"); + const hours = (NOW - Date.parse(calls[0].start)) / 3_600_000; + expect(hours).toBeCloseTo(24 * 7, 3); + }); + + test("a second call inside the TTL does not hit upstream again", async () => { + const calls: any[] = []; + const svc = new SparklineService(client(calls), { now: () => NOW }); + await svc.get(["BTC/USD"], "24h"); + await svc.get(["BTC/USD"], "24h"); + expect(calls).toHaveLength(1); + }); + + test("concurrent loads collapse into one paginated fetch", async () => { + const calls: any[] = []; + const svc = new SparklineService(client(calls), { now: () => NOW }); + await Promise.all([svc.get(["BTC/USD"], "24h"), svc.get(["BTC/USD"], "24h")]); + expect(calls).toHaveLength(1); + }); + + test("the two periods are cached separately", async () => { + const calls: any[] = []; + const svc = new SparklineService(client(calls), { now: () => NOW }); + await svc.get(["BTC/USD"], "24h"); + await svc.get(["BTC/USD"], "7d"); + expect(calls).toHaveLength(2); + }); + + test("a pair with a single bar is omitted, not drawn flat", async () => { + // One observation is not a trend; a flat line would assert a stability we + // never saw. + const thin = { getBars: async () => bars("BTC/USD", [100]) } as unknown as AlpacaCryptoClient; + const out = await new SparklineService(thin, { now: () => NOW }).get(["BTC/USD"], "24h"); + expect(out.has("BTC/USD")).toBe(false); + }); +}); diff --git a/test/dashboard-crypto.test.ts b/test/dashboard-crypto.test.ts index ccb83c2..05a24bc 100644 --- a/test/dashboard-crypto.test.ts +++ b/test/dashboard-crypto.test.ts @@ -124,6 +124,25 @@ function respond(rawUrl: string): unknown { disclaimer: CRYPTO_DISCLAIMER, }; } + if (p === "/crypto/sparklines") { + const period = url.searchParams.get("period") ?? "24h"; + // BTC rises, ETH falls, and DOGE is deliberately absent so the "no history" + // path is exercised. + const rise = period === "7d" ? [100, 120, 140, 160] : [100, 101, 102, 103]; + // ETH is down on the session in GRID_PRICES; its window rises, so the + // two directions disagree and the colour test can actually discriminate. + const fall = period === "7d" ? [140, 160, 180, 200] : [190, 195, 200]; + const mk = (symbol: string, pts: number[]) => [symbol, { + symbol, points: pts, first: pts[0], last: pts.at(-1), + changePercent: ((pts.at(-1)! - pts[0]!) / pts[0]!) * 100, + start: "2026-08-05T00:00:00Z", end: "2026-08-06T00:00:00Z", + }]; + return { + period, + series: Object.fromEntries([mk("BTC/USD", rise), mk("ETH/USD", fall)]), + disclaimer: CRYPTO_DISCLAIMER, + }; + } if (p === "/crypto/lookup") { return { query: url.searchParams.get("q"), @@ -231,6 +250,40 @@ describe("crypto tab", () => { expect(cards.every((c: any) => /^\/crypto\/[A-Z0-9]+-[A-Z]+$/.test(c.getAttribute("href")))).toBe(true); }); + test("cards draw a sparkline when there is history for one", () => { + const svg = cardFor("BTC/USD")!.querySelector(".cx-spark"); + expect(svg).toBeTruthy(); + // Two paths: the filled area and the line itself. + expect(svg!.querySelectorAll("path").length).toBe(2); + expect(svg!.getAttribute("preserveAspectRatio")).toBe("none"); + // Decorative: the numbers beside it carry the meaning. + expect(svg!.getAttribute("aria-hidden")).toBe("true"); + }); + + test("a pair with no series renders without a chart rather than a flat line", () => { + // DOGE is absent from the sparkline fixture but present in prices. + const doge = cardFor("DOGE/USD")!; + expect(doge.querySelector(".cx-spark")).toBeNull(); + // The card is still a card: price and link intact. + expect(doge.querySelector(".cx-price")!.textContent).toBe("$0.06893"); + expect(doge.getAttribute("href")).toBe("/crypto/DOGE-USD"); + }); + + test("the summary says how many pairs lack history", () => { + // Silence would read as a rendering bug rather than missing data. + expect(text("#cx-summary")).toContain("without 24h history"); + }); + + test("the sparkline is coloured by its own period, not the session", () => { + // ETH is DOWN on the session but UP across the sparkline window. Painting + // the line red because the day was red would misreport the window. + const eth = cardFor("ETH/USD")!; + expect(eth.classList.contains("negative")).toBe(true); // session direction + const stroke = eth.querySelector(".cx-spark path:last-of-type")!.getAttribute("stroke"); + expect(stroke).toBe("var(--pos)"); // window direction + expect(eth.querySelector(".cx-sparkchg")!.textContent).toContain("+5.3%"); + }); + test("prices keep precision across four orders of magnitude", () => { const priceOf = (pair: string) => cardFor(pair)?.querySelector(".cx-price")?.textContent; // The bug this guards: a fixed 2dp renders DOGE as "$0.00". @@ -256,6 +309,27 @@ describe("crypto tab", () => { }); }); +describe("sparkline period toggle", () => { + test("defaults to 24h and marks it active", () => { + const on = $$(".cx-period button").filter((b: any) => b.classList.contains("on")); + expect(on).toHaveLength(1); + expect(on[0].dataset.period).toBe("24h"); + }); + + test("switching to 7d redraws the cards from the 7d series", async () => { + const before = cardFor("BTC/USD")!.querySelector(".cx-sparkchg")!.textContent; + expect(before).toContain("+3.0%"); + + click($$(".cx-period button").find((b: any) => b.dataset.period === "7d")); + await sleep(300); + + const after = cardFor("BTC/USD")!.querySelector(".cx-sparkchg")!.textContent; + expect(after).toContain("+60.0%"); + expect($$(".cx-period button").find((b: any) => b.dataset.period === "7d")!.classList.contains("on")).toBe(true); + expect(text("#cx-summary")).toContain("without 7d history"); + }); +}); + describe("crypto lookup", () => { test("typing a name offers pairs", async () => { type($("#cx-find"), "bitcoin");