fx-dashboard-widgets

Build live currency/FX rate displays — a rate ticker, dashboard card, rate table, sparkline/chart, currency converter widget, Slack/CLI rate bot, or watch-a-pair alert. Use whenever the user wants to show exchange rates in a UI, even something as simple as "show the EUR/USD rate on my dashboard" — this skill covers the poller architecture, session-aware refresh, and truthful "as of" labeling so the widget doesn't burn API quota or lie about freshness. Powered by exchangerate.dev (keyless base https://api.exchangerate.dev).

nusantara-ventures/exchangerate-skills1 installsMITSynced Aug 27

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
name: fx-dashboard-widgets
description: Build live currency/FX rate displays — a rate ticker, dashboard card, rate table, sparkline/chart, currency converter widget, Slack/CLI rate bot, or watch-a-pair alert. Use whenever the user wants to show exchange rates in a UI, even something as simple as "show the EUR/USD rate on my dashboard" — this skill covers the poller architecture, session-aware refresh, and truthful "as of" labeling so the widget doesn't burn API quota or lie about freshness. Powered by exchangerate.dev (keyless base https://api.exchangerate.dev).
license: MIT
---

# FX dashboard widgets

Rate displays fail in two ways: they hammer the API from every open browser tab, or they show "live" on a Saturday. Both are architecture bugs, not styling bugs. This skill is the pattern for building a currency ticker, card, table, chart, or converter that's fast, quota-safe, and honest about freshness. Examples use exchangerate.dev (keyless, `https://api.exchangerate.dev`).

## 1. One poller, many clients — never per-browser API calls

A dashboard with 50 open tabs must not make 50 API calls. Poll once, server-side, cache the result, and serve every client from the cache:

```
[exchangerate.dev] <--poll-- [your server, 1 poller] --serves--> [N browser clients]
```

Why this matters beyond politeness: keyless calls are **12 req/min per IP**. One poller comfortably fits inside that; N independent browser tabs polling directly will collide on the same IP bucket and start 429ing each other in production.

Live currencies (27 of them) reprice roughly every **60 seconds** on trading days — polling faster than 60s buys you nothing, you're just re-fetching the same `data_updated_at`. A sane poll interval is 30-60s during `market_session: open`, checked against the rate-limit headers on every response:

```
x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset
```

If `x-ratelimit-remaining` hits 0, or you get a 429, back off until `x-ratelimit-reset` (unix seconds) — don't retry blind, and don't tighten your poll interval to "catch up."

## 2. Session-aware polling — don't poll a closed market

Every `/v1/latest` response carries `market_session`. Use it to drive the poll cadence, not just a fixed timer:

| `market_session` | Meaning | Poll behavior |
|---|---|---|
| `open` | Interbank trading | Normal cadence (30-60s) |
| `weekend` | Markets closed Sat/Sun | Drop to hourly, or stop — show "market closed" state |
| `interbank_closed` | Post-NY-close / pre-Sydney-open gap | Drop to hourly, or stop |

Rates genuinely do not change outside `open` — polling every 30s through a weekend is pure quota burn for a number that cannot move. Resume normal cadence the moment `market_session` flips back to `open`.

```typescript
function nextPollDelayMs(session: string): number {
  return session === "open" ? 45_000 : 60 * 60_000; // 45s live, hourly otherwise
}
```

## 3. UI truthfulness — say what you know, not what looks good

- **Never show "live" unconditionally.** Show `data_updated_at` — "updated 14:02 UTC" — and let the user judge freshness themselves.
- **A weekend flatline is correct, not a bug.** Don't chase it with a bogus "no data" state. Label the last point: "Fri close" or "as of Fri 21:59 UTC", sourced from `data_updated_at` on the last `open`-session poll.
- **Mixed tables need per-row provenance.** A table with EUR (`live`) next to a smaller currency (`ecb_daily`) should not present both the same way. Use the per-currency `sources` map from `/v1/latest` to badge each row — "live" vs "daily fix" — rather than trusting the top-level `source` (which reports the *least-fresh* tier across the whole response).
- **`market_session` drives a banner, not just the poller.** When session is `weekend` or `interbank_closed`, show a small "market closed" indicator next to the rate instead of a pulsing "live" dot.

## 4. Delta/change display — deltas need a real reference point

"+0.3%" is meaningless unless you say against what. The correct reference is the **previous business day's close**, not your own last poll:

```bash
# today's rate
curl "https://api.exchangerate.dev/v1/latest?base=USD&symbols=EUR"

# yesterday's close for the delta — mind weekends
curl "https://api.exchangerate.dev/v1/2026-07-03?base=USD&symbols=EUR"
```

Two traps:

- **"Yesterday" on Monday is Friday.** Don't naively subtract one calendar day — either compute the prior business day yourself, or just request calendar-yesterday and check `is_forward_filled` (if true on a Monday request for Sunday, it forward-filled to Friday, which is what you want anyway).
- **Don't compute deltas against your own last poll.** That's "change since I last checked," which drifts depending on when your poller happened to run — not a meaningful market statistic. Anchor every delta to a specific business-day close.

## 5. Sparklines and charts

```bash
curl "https://api.exchangerate.dev/v1/range?base=USD&symbols=EUR&start_date=2026-06-01&end_date=2026-07-06"
```

Row shape (business days only — weekends are absent rows, not nulls):

```json
{"date":"2024-01-02","rates":{"EUR":0.91274},"source":"ecb_daily","is_forward_filled":false,"derived_symbols":[]}
```

- **Plot on a time axis, not an index axis.** If you plot by array index, weekend gaps silently compress out and every week looks the same length. A time (date) x-axis renders the gaps correctly — a 3-day gap over a weekend looks like 3 days, not 1.
- **Paginate beyond 366 rows.** One page maxes at 366 rows; check `has_more` and follow `next_cursor` for longer ranges — don't assume a year of daily data fits in one call.

## 6. End-to-end example: poller + cache + endpoint

A minimal session-aware poller feeding a `/rates` JSON route (TypeScript):

```typescript
type RatesCache = {
  base: string;
  rates: Record<string, number>;
  sources: Record<string, string>;
  marketSession: string;
  dataUpdatedAt: string;
};

let cache: RatesCache | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;

async function pollOnce() {
  const res = await fetch(
    "https://api.exchangerate.dev/v1/latest?base=USD&symbols=EUR,GBP,JPY,IDR"
  );
  const remaining = Number(res.headers.get("x-ratelimit-remaining") ?? "1");
  if (res.status === 429 || remaining === 0) {
    const reset = Number(res.headers.get("x-ratelimit-reset") ?? "60");
    return schedule(Math.max(reset * 1000, 60_000));
  }
  const body = await res.json();
  cache = {
    base: body.base,
    rates: body.rates,
    sources: body.sources,
    marketSession: body.market_session,
    dataUpdatedAt: body.data_updated_at,
  };
  schedule(body.market_session === "open" ? 45_000 : 60 * 60_000);
}

function schedule(delayMs: number) {
  if (timer) clearTimeout(timer);
  timer = setTimeout(pollOnce, delayMs);
}

pollOnce(); // start the single server-side poller

// Express-style handler — every client reads the cache, nobody calls the API directly
export function ratesEndpoint(_req: unknown, res: { json: (b: unknown) => void }) {
  res.json(cache ?? { error: "warming up" });
}
```

The frontend widget just polls `/rates` on your own server (cheap, no external rate limit) and renders `dataUpdatedAt` + `marketSession` alongside the numbers.

One-liner CLI ticker, for a terminal dashboard or cron-driven Slack post:

```bash
curl -s "https://api.exchangerate.dev/v1/rate/eur-usd" | jq -r '"\(.pair): \(.rate) (\(.market_session), updated \(.data_updated_at))"'
```

## 7. Converter widgets

```bash
curl "https://api.exchangerate.dev/v1/convert/EUR/USD/100"
```

Use the server's `converted` field directly — it's already rounded to the target currency's minor units (0 for JPY, 3 for KWD, 2 for most). Don't multiply `rate` client-side and re-round; you'll drift from the API's own rounding on edge cases.

**Debounce input.** A converter bound to an `<input>` should debounce (250-400ms after the user stops typing) before calling `/v1/convert` — not fire a request per keystroke. Each call is metered; a fast typist on a 4-digit amount can burn 4+ calls for one intended conversion.

## Related skills in this repo

- `exchangerate-dev` — the API itself: auth, endpoints, MCP server, error handling.
- `fx-rates-correctness` — weekend gaps, forward-fill, precision, triangulation, staleness.
- `multi-currency-pricing` — adding multi-currency display prices to an app (different problem: pricing, not live-rate widgets).

For agent-hosted dashboards (a Claude/Cursor session building the widget interactively), the exchangerate.dev MCP server (`list_currencies`, `get_rate`, `convert`, `get_range`, `search_docs`) is a viable alternative to hand-rolled REST calls during development — swap to the REST poller above for the shipped, production widget.

More Data Engineering skills

← All Data Engineering skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY