From 687ff0725be644be3f637e2c310d89659f425f36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 13 Nov 2026 19:38:32 +0000 Subject: [PATCH 1/3] Add a live dashboard example for Connectors A no-build page that reads euro exchange rates through a team connector role: sf.jsonc declares the role with pinned arguments and a 15-minute cache, and index.html watches it through /__spacefast/client.js and renders loading, data, unbound, connect_required and forbidden states. The data source is Frankfurter's public OpenAPI spec, so anyone can reproduce it. CI builds the example; publishing stays manual because it needs a team connection with site serving. --- .github/workflows/publish.yml | 1 + README.md | 5 +- live-dashboard/README.md | 122 ++++++++++++++++++++++++ live-dashboard/index.html | 172 +++++++++++++++++++++++++++++++++ live-dashboard/sf.jsonc | 35 +++++++ live-dashboard/styles.css | 173 ++++++++++++++++++++++++++++++++++ 6 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 live-dashboard/README.md create mode 100644 live-dashboard/index.html create mode 100644 live-dashboard/sf.jsonc create mode 100644 live-dashboard/styles.css diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c4d63c6..1abf3c9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,6 +36,7 @@ jobs: - nextjs - astro-with-wordpress - functions-php + - live-dashboard - kitchen-sink - migrate-from-cloudflare-pages - migrate-from-vercel diff --git a/README.md b/README.md index d1ce635..225a4e9 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ everything else, use the framework's dev server. | [`nextjs/`](./nextjs/) | Server-rendered Next.js, route handlers, and automatic OpenNext packaging | | [`astro-with-wordpress/`](./astro-with-wordpress/) | Astro built from WordPress through `@spacefast/wordpress` | | [`functions-php/`](./functions-php/) | Native PHP Functions with JSON, form bodies, validation, and verified auth context | +| [`live-dashboard/`](./live-dashboard/) | A no-build page reading team data through a Connectors role, with pinned arguments, a shared cache, and every connector state (Private Beta) | | [`recipes/`](./recipes/) | Canonical prompts, metadata, live outputs, and the public recipe feed | | [`kitchen-sink/`](./kitchen-sink/) | Zero, Pages, PHP, JavaScript, TypeScript, auth, mail, and routing in one project | | [`migrate-from-cloudflare-pages/`](./migrate-from-cloudflare-pages/) | Wrangler build settings and Pages convention files | @@ -54,6 +55,8 @@ agent can customize and publish. The pull-request workflow builds every top-level project through `sf build`, runs the Zero app tests, validates every recipe output, and builds the recipe -manifest, without creating a live version. `migrate-from-netlify` builds its +manifest, without creating a live version. Only recipes are published; +`live-dashboard` needs a team connection with site serving, so it is published +by hand (see its README). `migrate-from-netlify` builds its prebuilt output, because `sf build` has no flag to accept the Netlify features that still need a port. diff --git a/live-dashboard/README.md b/live-dashboard/README.md new file mode 100644 index 0000000..80c8e4c --- /dev/null +++ b/live-dashboard/README.md @@ -0,0 +1,122 @@ +# Live dashboard through a connector + +A one-file dashboard of euro exchange rates. The page has no backend, no build +step, and no API key: it calls the Space's own +`/__spacefast/connectors/rates/` route, and Spacefast answers with your +team's connection. + +> Connectors are in **Private Beta**. Your team needs to be in the Connectors +> program; until it is, `sf connectors create` answers `feature_unavailable`. + +| File | What it does | +| --- | --- | +| `sf.jsonc` | Declares the `rates` role: one `team` connection, two read tools, pinned arguments, and a 15-minute cache | +| `index.html` | Imports `/__spacefast/client.js`, watches both tools every 60 seconds, and renders each state the route answers | +| `styles.css` | Light and dark styles | + +The data comes from [Frankfurter](https://frankfurter.dev), a free, open-source +API for European Central Bank reference rates. It needs no account and no key, +and it publishes an OpenAPI spec, so anyone can reproduce this example. + +## Set it up + +You need `spacefast` 0.9.0 or later. Run these from this directory, signed in +to the team that will own the Space. + +```sh +# 1. Register the connector from its OpenAPI spec. +sf connectors create https://api.frankfurter.dev/v1/openapi.json --slug frankfurter + +# 2. Connect it for the team. The API takes no credential, so this connects at once. +sf connectors connections connect frankfurter --owner team + +# 3. Publish. A role with exactly one eligible connection binds itself. +sf publish + +# 4. Let the connection serve sites. Owners and admins turn it on: +sf connectors connections update --serving +# Members ask instead, and an owner or admin approves: +sf connectors serving create rates +``` + +`sf connectors connections list` prints the connection id, and +`sf connectors bindings` shows whether `rates` is bound and ready. + +Team roles serve Spaces that need a sign-in. To let anyone with the share link +(or the public) read through this role, a team owner or admin also runs: + +```sh +sf connectors public-serving set rates --enable +``` + +## What the page proves + +- **No key in the browser.** The page only names a role and a tool. The + connection and its credential stay with Spacefast. +- **Pinned arguments.** `base` and `symbols` are fixed in `sf.jsonc` and applied + on the server. A page that sends a different value gets + `connector_arg_pinned`, so this page can only read the four rates it declares. +- **A tool allowlist.** The role lists two read tools. Any other tool on the + connector answers `connector_tool_not_allowed`. +- **One shared read.** Every viewer asking the same question in the same + 15 minutes shares one upstream request (`cache: "15m"`). The 60-second + refresh on the page only checks that shared answer. The footer shows when the + data was fetched upstream (`cachedAt`). +- **Real states.** Before anything is connected, the route answers a state + instead of data, and the page shows its message: + +| State | When | What the page shows | +| --- | --- | --- | +| `loading` | First request in flight | "Reading rates…" | +| `ok` | Bound, connected, serving | Four rate tiles with 30-day sparklines | +| `unbound` | No connection bound to `rates` yet | "The site owner needs to connect this service." | +| `connect_required` | A role needs the viewer's own account (visitor roles) | The message and a **Connect** button to `connectUrl` | +| `forbidden` (HTTP 403, `connection_forbidden`) | Site serving is off, public serving is not allowed, or team policy blocks the connector | "You do not have permission to use this connection." | +| Any other error | The service or route is unavailable | The route's message and **Try again** | + +`forbidden` arrives as an RFC 9457 problem, so the client reports it as +`state: "error"` with `error.code === "connection_forbidden"`. The tile data +from the last good answer stays on screen when a refresh fails. + +## The same page in React + +With a build step, install the client and let `sf dev` or `sf publish` write +the typed `spacefast.generated.ts` from this role (or run `sf client`): + +```sh +npm install @spacefast/client react +``` + +```tsx +import { useConnector } from "@spacefast/client/react"; +import { space } from "./spacefast.generated"; + +export function Rates() { + const latest = useConnector(space.connectors.rates["currentRates.getLatest"], {}, { every: "60s" }); + + if (latest.state === "loading") return

Reading rates…

; + if (!latest.data) return

{latest.message}

; + return ( +
    + {Object.entries(latest.data.rates).map(([symbol, rate]) => ( +
  • + EUR → {symbol}: {rate} +
  • + ))} +
+ ); +} +``` + +The generated types leave out the pinned `base` and `symbols`, so the +arguments object is empty. + +## Boundaries + +- `/__spacefast/client.js` and the connectors route exist only on a published + Space. Opened from disk or a plain static server, the page says so instead of + showing rates. `sf build` still packs it. +- This repository's workflow builds the example but never publishes it. The + Space needs a team in the Connectors program and a connection with site + serving, which only a person with those rights can set up, so publishing is + the manual steps above. diff --git a/live-dashboard/index.html b/live-dashboard/index.html new file mode 100644 index 0000000..a5a4760 --- /dev/null +++ b/live-dashboard/index.html @@ -0,0 +1,172 @@ + + + + + + Live Rates + + + +
+
+

Connectors · Private Beta

+

Euro, live

+

+ Reference rates from the European Central Bank, read through your team's + rates connector role. No backend, and no key in this page. +

+
+ +
+

Reading rates…

+
+ +
+
+ + + + diff --git a/live-dashboard/sf.jsonc b/live-dashboard/sf.jsonc new file mode 100644 index 0000000..b20bd39 --- /dev/null +++ b/live-dashboard/sf.jsonc @@ -0,0 +1,35 @@ +{ + "$schema": "https://spacefast.com/schemas/sf.json", + "name": "Live Rates", + "cleanUrls": true, + "listing": false, + "meta": { + "title": "Live Rates", + "description": "Euro exchange rates read through a team connector, with no backend and no keys in the page.", + }, + // One team role. The page calls /__spacefast/connectors/rates/; + // Spacefast answers with the team's connection, so no key reaches the browser. + "connectors": { + "rates": { + "connector": "frankfurter", + "as": "team", + "tools": ["currentRates.getLatest", "historicalRates.getStartDate"], + // Pinned on the server. A page that sends another base or symbols is refused. + "args": { + "currentRates.getLatest": { "base": "EUR", "symbols": ["USD", "GBP", "JPY", "CHF"] }, + "historicalRates.getStartDate": { "base": "EUR", "symbols": ["USD", "GBP", "JPY", "CHF"] }, + }, + // Every viewer shares one upstream read per tool and arguments for 15 minutes. + "cache": "15m", + }, + }, + "headers": [ + { + "source": "/*", + "headers": [ + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + ], + }, + ], +} diff --git a/live-dashboard/styles.css b/live-dashboard/styles.css new file mode 100644 index 0000000..70418cc --- /dev/null +++ b/live-dashboard/styles.css @@ -0,0 +1,173 @@ +:root { + color-scheme: light; + --surface: #fcfcfb; + --tile: #ffffff; + --line: #e4e3df; + --text: #0b0b0b; + --text-2: #52514e; + --accent: #2a78d6; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + --surface: #1a1a19; + --tile: #232322; + --line: #353533; + --text: #f0efec; + --text-2: #c3c2b7; + --accent: #3987e5; + } +} + +:root[data-theme="dark"] { + color-scheme: dark; + --surface: #1a1a19; + --tile: #232322; + --line: #353533; + --text: #f0efec; + --text-2: #c3c2b7; + --accent: #3987e5; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--surface); + color: var(--text); + line-height: 1.5; +} + +main { + max-width: 56rem; + margin: 0 auto; + padding: 3rem 16px; +} + +.eyebrow { + margin: 0; + color: var(--text-2); + font-size: 0.8125rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +h1 { + margin: 0.25rem 0 0.5rem; + font-size: clamp(2rem, 6vw, 3rem); + line-height: 1.1; +} + +.lede { + max-width: 40rem; + margin: 0 0 2rem; + color: var(--text-2); +} + +code { + font-size: 0.9em; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: 1rem; +} + +.tile { + padding: 1rem 1rem 0.75rem; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--tile); +} + +.tile h2 { + margin: 0; + color: var(--text-2); + font-size: 0.875rem; + font-weight: 500; +} + +.value { + margin: 0.25rem 0 0; + font-size: 2rem; + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.meta { + margin: 0 0 0.5rem; + color: var(--text-2); + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; +} + +.tile svg { + display: block; + width: 100%; + height: 3rem; + overflow: visible; + cursor: crosshair; +} + +.tile path { + fill: none; + stroke: var(--accent); + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; +} + +.tile .cursor { + stroke: var(--text-2); + stroke-width: 1; + opacity: 0; +} + +.tile .cursor.on { + opacity: 1; +} + +.notice { + padding: 1.5rem; + border: 1px dashed var(--line); + border-radius: 12px; + color: var(--text-2); +} + +.notice h2 { + margin: 0 0 0.25rem; + color: var(--text); + font-size: 1.125rem; +} + +.notice p { + margin: 0.25rem 0 0; +} + +.button { + display: inline-block; + margin-top: 0.5rem; + padding: 0.5rem 0.875rem; + border-radius: 8px; + background: var(--accent); + color: #fff; + font-weight: 500; + text-decoration: none; +} + +footer { + margin-top: 1.5rem; + color: var(--text-2); + font-size: 0.8125rem; +} + +footer strong { + display: block; + color: var(--text); + font-weight: 500; +} From 6b3b40937d0f03caf9102e74220c6651ac702d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 13 Nov 2026 22:19:53 +0000 Subject: [PATCH 2/3] Show the history read's own state, and never invent an upstream time The rate tiles no longer hide the 30-day history read: when it is loading, waiting for approval, needs a connection, is forbidden or fails, its message takes the sparklines' place, and a failed refresh of good history says the trends are from an earlier read. The footer shows the upstream fetch time only when the answer carries cachedAt; otherwise it shows when the page received the data, which a failed refresh keeps. --- live-dashboard/README.md | 10 ++++-- live-dashboard/index.html | 67 +++++++++++++++++++++++++++------------ live-dashboard/styles.css | 15 +++++++++ 3 files changed, 70 insertions(+), 22 deletions(-) diff --git a/live-dashboard/README.md b/live-dashboard/README.md index 80c8e4c..42c82a8 100644 --- a/live-dashboard/README.md +++ b/live-dashboard/README.md @@ -60,8 +60,9 @@ sf connectors public-serving set rates --enable connector answers `connector_tool_not_allowed`. - **One shared read.** Every viewer asking the same question in the same 15 minutes shares one upstream request (`cache: "15m"`). The 60-second - refresh on the page only checks that shared answer. The footer shows when the - data was fetched upstream (`cachedAt`). + refresh on the page only checks that shared answer. When the answer came + from that shared read, the footer shows when it was fetched upstream + (`cachedAt`); otherwise it shows when the page received it. - **Real states.** Before anything is connected, the route answers a state instead of data, and the page shows its message: @@ -78,6 +79,11 @@ sf connectors public-serving set rates --enable `state: "error"` with `error.code === "connection_forbidden"`. The tile data from the last good answer stays on screen when a refresh fails. +The two tools are separate reads with separate states. If the 30-day history +read is loading, waiting for approval, needs a connection, or fails, the rate +tiles still show and the history read's own message takes the sparklines' +place. + ## The same page in React With a build step, install the client and let `sf dev` or `sf publish` write diff --git a/live-dashboard/index.html b/live-dashboard/index.html index a5a4760..4369c4c 100644 --- a/live-dashboard/index.html +++ b/live-dashboard/index.html @@ -44,30 +44,36 @@

Euro, live

}); const time = new Intl.DateTimeFormat(undefined, { timeStyle: "short" }); - function notice(title, body = "", link = null) { - section.setAttribute("aria-busy", "false"); - section.innerHTML = ` -
+ function noticeHtml({ title, body = "", link = null }, className = "notice") { + return ` +

${escape(title)}

${body ? `

${escape(body)}

` : ""} ${link ? `

${escape(link.label)}

` : ""}
`; } + function notice(content) { + section.setAttribute("aria-busy", "false"); + section.innerHTML = noticeHtml(content); + } + // Every state the connector route answers, in the words it sends. - function renderState(snapshot) { + // `what` names the data, such as "rates", for the loading and failure titles. + function stateNotice(snapshot, what) { const { state, message, connectUrl, approvalUrl, error } = snapshot; + if (state === "loading") return { title: `Reading ${what}…` }; if (state === "unbound") - return notice(message, "Publisher: register the connector, connect it, and publish again. The README has the four commands."); + return { title: message, body: "Publisher: register the connector, connect it, and publish again. The README has the four commands." }; if (state === "connect_required" || state === "reconnect_required") - return notice(message, "", connectUrl && { href: connectUrl, label: "Connect" }); + return { title: message, link: connectUrl && { href: connectUrl, label: "Connect" } }; if (state === "approval_pending") - return notice(message, "", approvalUrl && { href: approvalUrl, label: "Review" }); - if (state === "running") return notice(message); + return { title: message, link: approvalUrl && { href: approvalUrl, label: "Review" } }; + if (state === "running") return { title: message }; // `forbidden` arrives as a 403 problem, so the client reports it as an error. if (error?.code === "connection_forbidden") - return notice(message, "A team owner or admin turns on site serving for this connection. If anyone with the share link opens this Space, they also allow public serving for the role."); - notice("Rates are unavailable right now.", message ?? "", { href: location.href, label: "Try again" }); + return { title: message, body: "A team owner or admin turns on site serving for this connection. If anyone with the share link opens this Space, they also allow public serving for the role." }; + return { title: `The ${what} are unavailable right now.`, body: message ?? "", link: { href: location.href, label: "Try again" } }; } function sparkline(points) { @@ -86,8 +92,10 @@

${escape(title)}

`; } - function renderRates(latest, history) { - const series = history?.rates ?? {}; + // `past` is the history read's snapshot. Its data draws the sparklines; + // without data, its own state takes their place. + function renderRates(latest, past) { + const series = past.data?.rates ?? {}; const days = Object.keys(series).sort(); section.setAttribute("aria-busy", "false"); section.innerHTML = `
${SYMBOLS.map((symbol) => { @@ -107,7 +115,14 @@

EUR → ${symbol}

${sparkline(points)} `; - }).join("")}
`; + }).join("")}
${historyNote(past)}`; + } + + function historyNote(past) { + if (!past.data) return noticeHtml(stateNotice(past, `${DAYS}-day trends`), "notice small"); + if (past.state === "error") + return `

The ${DAYS}-day history is from an earlier read: ${escape(past.message)}

`; + return ""; } // Hover a sparkline to read one day. @@ -139,7 +154,10 @@

EUR → ${symbol}

// Served by Spacefast on every Space; there is nothing to install. ({ space } = await import("/__spacefast/client.js")); } catch { - notice("Publish this page to see it work.", "The connector client is served by Spacefast at /__spacefast/client.js, so it does not load from a local file or a plain static server."); + notice({ + title: "Publish this page to see it work.", + body: "The connector client is served by Spacefast at /__spacefast/client.js, so it does not load from a local file or a plain static server.", + }); } if (space) { @@ -148,19 +166,28 @@

EUR → ${symbol}

const latest = rates["currentRates.getLatest"].watch({}, { every: "60s" }); const history = rates["historicalRates.getStartDate"].watch({ start_date: since }, { every: "60s" }); + // When this page received the data on screen. A failed refresh keeps + // the old data, and so keeps this time too. + let shown = null; + let receivedAt = null; + const render = () => { const now = latest.getSnapshot(); const past = history.getSnapshot(); if (now.data) { - renderRates(now.data, past.data); - const fetched = now.cachedAt ? new Date(now.cachedAt) : new Date(); + if (now.data !== shown) [shown, receivedAt] = [now.data, new Date()]; + renderRates(now.data, past); + // `cachedAt` is the upstream fetch time of a shared read. A read + // fetched for this request has none, so the page says only when it arrived. + const when = now.cachedAt + ? `Fetched upstream at ${time.format(new Date(now.cachedAt))}; every viewer shares a read for 15 minutes.` + : `Received at ${time.format(receivedAt)}.`; footer.innerHTML = ` - ECB reference rates for ${escape(now.data.date)}. - Fetched upstream at ${time.format(fetched)} and shared with every viewer for 15 minutes. + ECB reference rates for ${escape(now.data.date)}. ${when} ${now.state === "error" ? `Showing the last good answer: ${escape(now.message)}` : ""} ${now.refreshing ? "Checking…" : ""}`; } else if (now.state !== "loading") { - renderState(now); + notice(stateNotice(now, "rates")); footer.textContent = ""; } }; diff --git a/live-dashboard/styles.css b/live-dashboard/styles.css index 70418cc..85a0913 100644 --- a/live-dashboard/styles.css +++ b/live-dashboard/styles.css @@ -171,3 +171,18 @@ footer strong { color: var(--text); font-weight: 500; } + +.notice.small { + margin-top: 1rem; + padding: 0.875rem 1rem; +} + +.notice.small h2 { + font-size: 0.9375rem; +} + +.stale { + margin: 0.75rem 0 0; + color: var(--text-2); + font-size: 0.8125rem; +} From dc0790fc1354d78c8cfc1ad1717af26057f531b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 13 Nov 2026 22:24:12 +0000 Subject: [PATCH 3/3] Keep the readout across refreshes, roll the history window daily, and add keyboard access Refreshes that bring the same answer leave the cards alone, and a redraw restores the day being read and the keyboard focus. The 30-day window is worked out at each render in UTC days, so a tab left open moves to the new day's shared read and drops the old watcher. Each sparkline is a focusable slider: arrow keys, Page Up/Down, Home and End read one day, with the same readout as the pointer and an aria-valuetext. --- live-dashboard/README.md | 6 +- live-dashboard/index.html | 140 ++++++++++++++++++++++++++++++-------- live-dashboard/styles.css | 6 ++ 3 files changed, 123 insertions(+), 29 deletions(-) diff --git a/live-dashboard/README.md b/live-dashboard/README.md index 42c82a8..e1ff85a 100644 --- a/live-dashboard/README.md +++ b/live-dashboard/README.md @@ -11,7 +11,7 @@ team's connection. | File | What it does | | --- | --- | | `sf.jsonc` | Declares the `rates` role: one `team` connection, two read tools, pinned arguments, and a 15-minute cache | -| `index.html` | Imports `/__spacefast/client.js`, watches both tools every 60 seconds, and renders each state the route answers | +| `index.html` | Imports `/__spacefast/client.js`, watches both tools every 60 seconds, and renders each state the route answers. Each sparkline reads one day by pointer or by keyboard (focus it, then use the arrow keys, Home and End) | | `styles.css` | Light and dark styles | The data comes from [Frankfurter](https://frankfurter.dev), a free, open-source @@ -60,7 +60,9 @@ sf connectors public-serving set rates --enable connector answers `connector_tool_not_allowed`. - **One shared read.** Every viewer asking the same question in the same 15 minutes shares one upstream request (`cache: "15m"`). The 60-second - refresh on the page only checks that shared answer. When the answer came + refresh on the page only checks that shared answer. The 30-day window is + worked out at each refresh in UTC days, so a tab left open overnight moves to + the new day's window and keeps sharing it with new visitors. When the answer came from that shared read, the footer shows when it was fetched upstream (`cachedAt`); otherwise it shows when the page received it. - **Real states.** Before anything is connected, the route answers a state diff --git a/live-dashboard/index.html b/live-dashboard/index.html index 4369c4c..7a75d5d 100644 --- a/live-dashboard/index.html +++ b/live-dashboard/index.html @@ -30,10 +30,6 @@

Euro, live

const SYMBOLS = ["USD", "GBP", "JPY", "CHF"]; const DAYS = 30; - // The 30-day window starts on a whole day, so every viewer asks the same - // question that day and shares one cached read. - const since = new Date(Date.now() - DAYS * 86_400_000).toISOString().slice(0, 10); - const escape = (text) => String(text).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); const rate = new Intl.NumberFormat(undefined, { maximumSignificantDigits: 5 }); @@ -76,7 +72,7 @@

${escape(title)}

return { title: `The ${what} are unavailable right now.`, body: message ?? "", link: { href: location.href, label: "Try again" } }; } - function sparkline(points) { + function sparkline(symbol, points) { if (points.length < 2) return ""; const values = points.map((p) => p.value); const min = Math.min(...values); @@ -84,8 +80,11 @@

${escape(title)}

const x = (i) => (i / (points.length - 1)) * 100; const y = (v) => 30 - ((v - min) / (max - min || 1)) * 26 - 2; const d = points.map((p, i) => `${i ? "L" : "M"}${x(i).toFixed(2)},${y(p.value).toFixed(2)}`).join(""); - const label = `${DAYS}-day range ${rate.format(min)} to ${rate.format(max)}`; - return ` @@ -107,13 +106,13 @@

${escape(title)}

const first = points[0]?.value; const change = first ? now / first - 1 : null; return ` -
+

EUR → ${symbol}

${rate.format(now)}

${change === null ? " " : `${percent.format(change)} in ${DAYS} days`}

- ${sparkline(points)} + ${sparkline(symbol, points)}
`; }).join("")}${historyNote(past)}`; } @@ -125,30 +124,90 @@

EUR → ${symbol}

return ""; } - // Hover a sparkline to read one day. - section.addEventListener("pointermove", (event) => { - const svg = event.target.closest?.("svg[data-points]"); - if (!svg) return; - const points = JSON.parse(svg.dataset.points); - const box = svg.getBoundingClientRect(); - const i = Math.round(((event.clientX - box.left) / box.width) * (points.length - 1)); - const point = points[Math.max(0, Math.min(points.length - 1, i))]; + // The day a viewer is reading, by pointer or keyboard. It outlives a + // redraw, so a refresh does not snap the readout back to today. + let selection = null; // { symbol, day } + // True while the cards are replaced, so losing focus to a redraw is not leaving. + let redrawing = false; + + const pointsOf = (svg) => JSON.parse(svg.dataset.points); + + function showDay(svg, index) { + const points = pointsOf(svg); + const i = Math.max(0, Math.min(points.length - 1, index)); + const point = points[i]; const tile = svg.closest(".tile"); tile.querySelector(".value").textContent = rate.format(point.value); tile.querySelector(".meta").textContent = point.day; - const x = ((points.indexOf(point) / (points.length - 1)) * 100).toFixed(2); + const x = ((i / (points.length - 1)) * 100).toFixed(2); const cursor = svg.querySelector(".cursor"); cursor.setAttribute("x1", x); cursor.setAttribute("x2", x); cursor.classList.add("on"); + svg.setAttribute("aria-valuenow", String(i)); + svg.setAttribute("aria-valuetext", `${point.day}: ${rate.format(point.value)}`); + selection = { symbol: tile.dataset.symbol, day: point.day }; + } + + function clearDay(tile) { + for (const el of tile.querySelectorAll("[data-default]")) el.textContent = el.dataset.default || "\u00a0"; + tile.querySelector(".cursor")?.classList.remove("on"); + if (selection?.symbol === tile.dataset.symbol) selection = null; + } + + const selectedIndex = (svg) => { + const symbol = svg.closest(".tile").dataset.symbol; + const points = pointsOf(svg); + const i = selection?.symbol === symbol ? points.findIndex((p) => p.day === selection.day) : -1; + return i === -1 ? points.length - 1 : i; + }; + + section.addEventListener("pointermove", (event) => { + const svg = event.target.closest?.("svg[data-points]"); + if (!svg) return; + const box = svg.getBoundingClientRect(); + showDay(svg, Math.round(((event.clientX - box.left) / box.width) * (pointsOf(svg).length - 1))); }); section.addEventListener("pointerout", (event) => { const tile = event.target.closest?.(".tile"); if (!tile || tile.contains(event.relatedTarget)) return; - for (const el of tile.querySelectorAll("[data-default]")) el.textContent = el.dataset.default || "\u00a0"; - tile.querySelector(".cursor")?.classList.remove("on"); + if (document.activeElement !== tile.querySelector("svg")) clearDay(tile); + }); + section.addEventListener("focusin", (event) => { + const svg = event.target.closest?.("svg[data-points]"); + if (svg) showDay(svg, selectedIndex(svg)); + }); + section.addEventListener("focusout", (event) => { + const tile = event.target.closest?.(".tile"); + if (tile && !redrawing) clearDay(tile); + }); + section.addEventListener("keydown", (event) => { + const svg = event.target.closest?.("svg[data-points]"); + if (!svg) return; + const last = pointsOf(svg).length - 1; + const now = selectedIndex(svg); + const next = { ArrowLeft: now - 1, ArrowDown: now - 1, ArrowRight: now + 1, ArrowUp: now + 1, + PageDown: now - 5, PageUp: now + 5, Home: 0, End: last }[event.key]; + if (next === undefined) return; + event.preventDefault(); + showDay(svg, next); }); + // Redraw the cards, keeping the day being read and the keyboard focus. + function redraw(latest, past) { + const kept = selection; + const focused = document.activeElement?.closest?.(".tile")?.dataset.symbol; + redrawing = true; + renderRates(latest, past); + redrawing = false; + selection = null; + const svgFor = (symbol) => section.querySelector(`.tile[data-symbol="${symbol}"] svg`); + if (focused) svgFor(focused)?.focus(); + const svg = kept && svgFor(kept.symbol); + const i = svg ? pointsOf(svg).findIndex((p) => p.day === kept.day) : -1; + if (i !== -1) showDay(svg, i); + } + let space = null; try { // Served by Spacefast on every Space; there is nothing to install. @@ -164,19 +223,44 @@

EUR → ${symbol}

const rates = space.connectors.rates; // `base` and `symbols` are pinned in sf.jsonc; the page sends only what is left. const latest = rates["currentRates.getLatest"].watch({}, { every: "60s" }); - const history = rates["historicalRates.getStartDate"].watch({ start_date: since }, { every: "60s" }); + + // The 30-day window starts on a whole UTC day, so every viewer asks the + // same question that day and shares one cached read. It is worked out on + // every render, which the 60-second refresh drives; when the day turns, + // the page watches the new window, drops the old one, and keeps showing + // the old answer until the new window has its own. + const windowStart = () => new Date(Date.now() - DAYS * 86_400_000).toISOString().slice(0, 10); + let history = null; + function watchHistory() { + const since = windowStart(); + if (history?.since === since) return; + const previous = history?.watch.getSnapshot() ?? null; + history?.unsubscribe(); + const watch = rates["historicalRates.getStartDate"].watch({ start_date: since }, { every: "60s" }); + history = { since, watch, previous, unsubscribe: null }; + history.unsubscribe = watch.subscribe(() => render()); + } // When this page received the data on screen. A failed refresh keeps // the old data, and so keeps this time too. let shown = null; let receivedAt = null; + // What the cards show. A refresh that brings the same rates and states + // (each refresh parses a new object, so compare the content) leaves them alone. + let drawn = ""; - const render = () => { + function render() { + watchHistory(); const now = latest.getSnapshot(); - const past = history.getSnapshot(); + let past = history.watch.getSnapshot(); + if (past.state === "loading" && history.previous?.data) past = history.previous; if (now.data) { if (now.data !== shown) [shown, receivedAt] = [now.data, new Date()]; - renderRates(now.data, past); + const inputs = JSON.stringify([now.data, past.data, past.state, past.message, past.connectUrl, past.approvalUrl]); + if (inputs !== drawn) { + drawn = inputs; + redraw(now.data, past); + } // `cachedAt` is the upstream fetch time of a shared read. A read // fetched for this request has none, so the page says only when it arrived. const when = now.cachedAt @@ -187,12 +271,14 @@

EUR → ${symbol}

${now.state === "error" ? `Showing the last good answer: ${escape(now.message)}` : ""} ${now.refreshing ? "Checking…" : ""}`; } else if (now.state !== "loading") { + drawn = ""; notice(stateNotice(now, "rates")); footer.textContent = ""; } - }; - latest.subscribe(render); - history.subscribe(render); + } + // Subscribing starts the first read and calls render; it comes last so + // everything render uses is in place. + latest.subscribe(() => render()); } diff --git a/live-dashboard/styles.css b/live-dashboard/styles.css index 85a0913..f62c4f8 100644 --- a/live-dashboard/styles.css +++ b/live-dashboard/styles.css @@ -186,3 +186,9 @@ footer strong { color: var(--text-2); font-size: 0.8125rem; } + +.tile svg:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 4px; + border-radius: 4px; +}