# Analytiics live stats pill

Add a small “3 people here right now · live stats” link like the one on
[Diiverge](https://www.diiverge.co/). Style it to match your app. Clicking it opens
the project's public Analytiics dashboard.

## Public stats are required

Finish [setup and instrumentation](/docs/agents) first. In the project's
Analytiics settings, publish the dashboard and enable its realtime widget.
Review the other visible widgets and events before publishing: this endpoint
returns the public count and filtered activity feed, not a private count-only
view. Do not publish someone's dashboard just to make a pill work without their
permission. Keep the pill absent if stats should stay private.

Use the exact `project` value from `analytics.yaml`, which may differ from a
legacy project's website domain. No write key, API token, cookies or server
proxy are needed. Never put `ANALYTIICS_WRITE_KEY` in this component.

```text
GET https://www.analytiics.co/api/online?project=example.com
Link: https://www.analytiics.co/p/example.com
```

A successful response contains only `online` (a non-negative integer), with a
30-second shared cache and no activity-feed query. It counts unique visitors with recorded
activity in the trailing five minutes; it is not an exact count of open tabs.
Avoid UTM parameters on the dashboard link: they filter that dashboard's data.

## React / Next.js example

Place this client component in your header or navigation once. It uses ordinary
CSS, so it does not require Tailwind. Replace the project in the usage example.
It polls every 30 seconds while visible, never overlaps requests, times out slow
requests, and hides on zero, invalid data or failure instead of inventing a count.

```tsx
"use client";

import { useEffect, useState } from "react";

export function LiveStatsPill({ project }: { project: string }) {
  const [online, setOnline] = useState<number | null>(null);
  const encoded = encodeURIComponent(project);

  useEffect(() => {
    let active = true;
    let timer: ReturnType<typeof setTimeout> | undefined;
    let request: AbortController | undefined;
    setOnline(null);

    const stop = () => {
      clearTimeout(timer);
      const previous = request;
      request = undefined;
      previous?.abort();
    };

    const load = async () => {
      if (!active || document.hidden) return;
      const controller = new AbortController();
      request = controller;
      const timeout = setTimeout(() => controller.abort(), 8000);
      let delay = 30000;
      try {
        const response = await fetch(
          "https://www.analytiics.co/api/online?project=" + encoded,
          { signal: controller.signal, credentials: "omit", cache: "no-store" },
        );
        if (response.status === 429) {
          delay = 60000;
          const retry = response.headers.get("Retry-After");
          const seconds = Number(retry);
          const wait = Number.isFinite(seconds)
            ? seconds * 1000
            : Date.parse(retry ?? "") - Date.now();
          if (Number.isFinite(wait)) delay = Math.max(delay, wait);
        }
        if (!response.ok) throw new Error("Live stats unavailable");
        const data = await response.json();
        if (!Number.isSafeInteger(data.online) || data.online < 0) {
          throw new Error("Invalid live count");
        }
        if (active && request === controller) setOnline(data.online);
      } catch {
        if (active && request === controller) setOnline(null);
      } finally {
        clearTimeout(timeout);
        if (request === controller) {
          request = undefined;
          if (active && !document.hidden) timer = setTimeout(load, delay);
        }
      }
    };

    const visibilityChanged = () => {
      stop();
      setOnline(null);
      if (!document.hidden) void load();
    };
    void load();
    document.addEventListener("visibilitychange", visibilityChanged);
    return () => {
      active = false;
      stop();
      document.removeEventListener("visibilitychange", visibilityChanged);
    };
  }, [encoded]);

  if (online === null || online < 1) return null;
  return (
    <a className="live-stats-pill"
      href={"https://www.analytiics.co/p/" + encoded}
      target="_blank" rel="noopener noreferrer"
      title="Visitors active in the last five minutes; opens live stats in a new tab">
      <span className="live-stats-dot" aria-hidden="true" />
      <span>{online.toLocaleString()} {online === 1 ? "person" : "people"} here right now</span>
      <span className="live-stats-label">· live stats</span>
    </a>
  );
}
```

Add these styles to your app's stylesheet. They inherit its text color and work
on light or dark backgrounds. The dot is static, avoiding motion distractions;
if you add a pulse, disable it with `prefers-reduced-motion: reduce`.

```css
.live-stats-pill {
  display: inline-flex;
  align-items: center;
  gap: .6rem;
  padding: .55rem .85rem;
  border: 1px solid currentColor;
  border-radius: 999px;
  color: inherit;
  background: transparent;
  text-decoration: none;
  font: 500 .75rem/1.3 system-ui, sans-serif;
}
.live-stats-pill:hover { text-decoration: underline; }
.live-stats-pill:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
.live-stats-dot { width: .5rem; height: .5rem; border-radius: 50%; background: #10b981; flex-shrink: 0; }
@media (max-width: 640px) { .live-stats-label { display: none; } }
```

```tsx
<LiveStatsPill project="example.com" />
```

For Vue, Svelte or plain JavaScript, use the same endpoint, link and lifecycle:
fetch once on mount, schedule the next request after completion, stop while
hidden, clear stale counts, and abort/clean up when removed. This is a display
widget; it does not install the tracker or send pageviews itself.

## Verify and troubleshoot

- Visit the instrumented app, then check the endpoint and pill. Use the same
  exact project value for tracker, manifest, endpoint and dashboard link.
- Check the public dashboard signed out. A private or realtime-disabled project
  returns `404` with `code: realtime_disabled`; hide the pill. Publishing other
  widgets alone is insufficient. Unpublishing should hide it on the next poll.
- A real zero renders nothing in this example. Never clamp the count to one.
  Localhost tracking is off by default; follow the deliberate development-test
  instructions in the [agent guide](/docs/agents).
- On `429`, wait at least 60 seconds and honor `Retry-After` when readable. The
  current cross-origin response does not expose that header to browsers, so
  the 60-second fallback matters. On other failures, hide and retry later.
- If the app has a Content Security Policy, allow `https://www.analytiics.co`
  in `connect-src` for this fetch; the tracker separately uses
  `https://in.analytiics.co`. Don't disable the policy or use `mode: "no-cors"`.
- Do not put a CDN, service worker cache or cached proxy in front of this public
  response. It uses `private, no-store` so publication is checked on every poll.
  Public responses can include a filtered activity feed: never use a privileged
  server request as a workaround for a private dashboard.

See the [online count API reference](/docs/api) for the full response and rate limits.
