diff --git a/docs/clipboard.md b/docs/clipboard.md index 34fbe0c..a48f65f 100644 --- a/docs/clipboard.md +++ b/docs/clipboard.md @@ -29,6 +29,9 @@ Callers branch on `result.ok`: `TextField` so it can still be copied by hand. - `events/Client.tsx` shows an error toast on failure and expands the event's payload `
` block so it's visible and selectable.
+- `stats/Client.tsx` confirms successful snapshot copies with a toast; on
+  failure it shows an error toast and the exact snapshot in a selectable,
+  read-only text area.
 
 Never call `navigator.clipboard.writeText` directly in page/component code —
 route it through `writeToClipboard` so insecure-context and permission
diff --git a/docs/stats-export.md b/docs/stats-export.md
index 866e896..3ff137b 100644
--- a/docs/stats-export.md
+++ b/docs/stats-export.md
@@ -1,7 +1,7 @@
 # Stats snapshot export
 
-The Stats page (`src/app/stats/Client.tsx`) can export a point-in-time
-snapshot of the router metrics shown in its `StatTile` grid, as JSON or CSV.
+The Stats page (`src/app/stats/Client.tsx`) can copy or download a
+point-in-time snapshot of the router metrics shown in its `StatTile` grid.
 
 ## Snapshot shape
 
@@ -31,17 +31,41 @@ display string:
 
 ## Serialisation
 
-Two pure, DOM-free functions turn a snapshot into text:
+Three pure, DOM-free functions turn a snapshot into text:
 
+- `statsSnapshotToText(snapshot)` — a concise heading, one line per displayed
+  metric, and the ISO 8601 capture timestamp.
 - `statsSnapshotToJson(snapshot)` — `JSON.stringify(snapshot, null, 2)`.
 - `statsSnapshotToCsv(snapshot)` — a `label,value,display,capturedAt` header
   followed by one row per metric. Fields containing a comma, quote, or
   newline (e.g. `formatNumber`'s thousands separators) are quoted and
   internal quotes doubled, per standard CSV escaping.
 
-Both are exported from `src/app/stats/Client.tsx` and can be unit tested
+All three are exported from `src/app/stats/Client.tsx` and can be unit tested
 without touching the DOM.
 
+The plain-text form mirrors the values visible in the tiles. For example:
+
+```text
+StableRoute stats snapshot
+Pairs: 1,234
+Status: Live
+Captured: 2026-07-23T05:30:00.000Z
+```
+
+## Copy
+
+The **Copy stats snapshot** button passes the plain-text form to the shared
+`writeToClipboard` helper. A successful write produces a notification. The
+button is disabled while the write is pending so repeated clicks cannot start
+duplicate clipboard operations.
+
+If the Clipboard API is unavailable or permission is denied, an error
+notification explains the failure and the page shows the exact snapshot in a
+read-only text area. Focusing that field selects all of its contents so the
+operator can copy it manually. This fallback also works when the current stats
+contain zero pairs.
+
 ## Download
 
 `downloadStatsSnapshot(data, format)` (`format` is `"json"` or `"csv"`)
@@ -60,6 +84,7 @@ filesystems.
 
 ## UI
 
-Once stats load successfully, the Stats page renders **Download JSON** and
-**Download CSV** buttons next to the metric tiles. They're hidden while
-loading or on error, since there's no snapshot to export yet.
+Once stats load successfully, the Stats page renders **Copy stats snapshot**,
+**Download JSON**, and **Download CSV** buttons next to the metric tiles.
+They're hidden while loading or on error, since there's no snapshot to export
+yet.
diff --git a/src/app/stats/Client.tsx b/src/app/stats/Client.tsx
index b897a41..2a7e7c9 100644
--- a/src/app/stats/Client.tsx
+++ b/src/app/stats/Client.tsx
@@ -1,12 +1,14 @@
 'use client';
 
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
 import { useApi } from '@/lib/useApi';
 import { formatNumber, formatTimestamp } from '@/lib/format';
 import { Button } from '@/components/Button';
 import { EmptyState } from '@/components/EmptyState';
 import { Spinner } from '@/components/Spinner';
 import { StatTile } from '@/components/StatTile';
+import { useToast } from '@/components/ToastProvider';
+import { writeToClipboard } from '@/lib/clipboard';
 
 type Stats = { totalPairs: number; paused: boolean };
 
@@ -89,6 +91,15 @@ export function statsSnapshotToJson(snapshot: StatsSnapshot): string {
   return JSON.stringify(snapshot, null, 2);
 }
 
+/** Pure serialiser: concise plain text using the values shown in the UI. */
+export function statsSnapshotToText(snapshot: StatsSnapshot): string {
+  return [
+    'StableRoute stats snapshot',
+    ...snapshot.metrics.map((metric) => `${metric.label}: ${metric.display}`),
+    `Captured: ${snapshot.capturedAt}`,
+  ].join('\n');
+}
+
 function csvEscape(field: string): string {
   if (/["\n,]/.test(field)) {
     return `"${field.replace(/"/g, '""')}"`;
@@ -152,8 +163,12 @@ export function downloadStatsSnapshot(
 }
 
 export default function StatsClient() {
+  const { push } = useToast();
   const result = useApi('/api/v1/stats');
   const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
+  const [isCopying, setIsCopying] = useState(false);
+  const [copyFallback, setCopyFallback] = useState(null);
+  const copyInFlightRef = useRef(false);
   const { refetch } = result;
   const status = result.status;
   const error = status === 'error' ? result.error : null;
@@ -168,6 +183,29 @@ export default function StatsClient() {
     if (status === 'success' && data) setLastUpdatedAt(Date.now());
   }, [status, data]);
 
+  const copyStatsSnapshot = async (stats: Stats) => {
+    if (copyInFlightRef.current) return;
+    copyInFlightRef.current = true;
+    setIsCopying(true);
+    try {
+      const text = statsSnapshotToText(buildStatsSnapshot(stats));
+      const copyResult = await writeToClipboard(text);
+      if (copyResult.ok) {
+        setCopyFallback(null);
+        push('Stats snapshot copied.');
+        return;
+      }
+      setCopyFallback(text);
+      push(
+        "Couldn't copy automatically. Select the snapshot below to copy it.",
+        'error'
+      );
+    } finally {
+      copyInFlightRef.current = false;
+      setIsCopying(false);
+    }
+  };
+
   return (
     
{lastUpdatedAt !== null && } -
+
+
+ {copyFallback !== null && ( +