---
name: himetrica-admin-kpis
description: Add a KPI header to an admin panel's home/overview page with Himetrica data - visitors, conversion rate, MRR, active subscriptions - each with period-over-period trend, plus a daily visitors sparkline. Use when the user wants traffic, conversion or revenue metrics, growth numbers, or a stats/KPI row on their admin dashboard or back-office home.
---

# Himetrica KPI header

Goal: the top of the admin home shows the numbers the app's own database doesn't have
(traffic, conversion) next to revenue, each with a trend vs the previous period.

Requires `himetrica-install` on the product. Revenue tiles need a revenue integration
connected in Himetrica (Stripe, RevenueCat, ...); without one they are hidden.

## 0. Gather

- Env (server only): `HIMETRICA_SECRET_KEY` (`hm_sk_...`) and `HIMETRICA_PROJECT_ID`
  (`GET https://app.himetrica.com/api/v1/me` returns it).
- The admin home page, its layout, UI kit, and whether it already shows any of these
  numbers from its own DB. If the panel already computes MRR/subscriptions from its own
  Stripe tables, ask the user which source to keep; never show the same metric twice.
- Chart library already in the project, if any.

## 1. Server client (create once, reuse if another himetrica-* skill already made it)

The secret key must only ever run on the server. If the app has no server (pure SPA on
Supabase/Firebase), put this in a serverless/edge function (Supabase Edge Function,
Vercel/Netlify function) and call that from the SPA. Never `NEXT_PUBLIC_`/`VITE_` it.

```ts
// lib/himetrica.server.ts  (server-only; in Next.js add: import "server-only";)
const BASE_URL = "https://app.himetrica.com/api/v1";
type Params = Record<string, string | number | boolean | undefined>;
const cache = new Map<string, { at: number; data: unknown }>();

export class HimetricaError extends Error {
  constructor(public status: number, public code: string, message: string) {
    super(message);
  }
}

export async function himetrica<T>(path: string, params: Params = {}, ttlSeconds = 60): Promise<T> {
  const key = process.env.HIMETRICA_SECRET_KEY;
  const projectId = process.env.HIMETRICA_PROJECT_ID;
  if (!key || !projectId) throw new HimetricaError(500, "NOT_CONFIGURED", "Missing HIMETRICA_SECRET_KEY or HIMETRICA_PROJECT_ID");

  const url = new URL(`${BASE_URL}/projects/${projectId}${path}`);
  for (const [k, v] of Object.entries(params)) if (v !== undefined) url.searchParams.set(k, String(v));

  const hit = cache.get(url.href);
  if (hit && Date.now() - hit.at < ttlSeconds * 1000) return hit.data as T;

  const res = await fetch(url, { headers: { "X-API-Key": key }, cache: "no-store" });
  const body = await res.json().catch(() => ({}));
  if (!res.ok) throw new HimetricaError(res.status, body.code ?? "HTTP_ERROR", body.error ?? res.statusText);

  if (cache.size > 500) cache.clear();
  cache.set(url.href, { at: Date.now(), data: body.data });
  return body.data as T;
}
```

(Deno/Supabase Edge: `Deno.env.get(...)` instead of `process.env`.)

## 2. Endpoints

All take ISO `startDate`/`endDate`; `timezone` defaults to the project's.

`GET /analytics?startDate&endDate` → trend is % change vs the previous period of the same
length (can be negative, can be `null`):

```ts
type Metric = { value: number; trend: number | null };
type Analytics = {
  uniqueVisitors: Metric; pageViews: Metric; sessions: Metric;
  bounceRate: Metric;        // percent, lower is better
  conversionRate: Metric;    // percent of visitors who converted, as defined in Himetrica
  monthlyActiveUsers: number;
};
```

`GET /analytics/timeseries?startDate&endDate&granularity=day`:
`Array<{ date: string; visitors: string; pageViews: string; sessions: string; newVisitors: string }>`
Counts come as strings: wrap with `Number()`.

`GET /revenue` → `data` is `null` when no integration is connected:

```ts
type Revenue = {
  mrrUsd: number; totalRevenueUsd: number;
  activeSubscriptions: number; activeTrials: number;
  churnRate: number;         // percent
  currency: string;
} | null;
```

## 3. Loader

One server function, three calls in parallel, cached 5 min (pass `300` as the helper's
TTL). Round `endDate` down to the minute so the cache key is stable.

```ts
const end = new Date(Math.floor(Date.now() / 60_000) * 60_000);
const start = new Date(end.getTime() - days * 86_400_000);
const range = { startDate: start.toISOString(), endDate: end.toISOString() };

const [analytics, series, revenue] = await Promise.allSettled([
  himetrica<Analytics>("/analytics", range, 300),
  himetrica<Array<{ date: string; visitors: string }>>("/analytics/timeseries", { ...range, granularity: "day" }, 300),
  himetrica<Revenue>("/revenue", {}, 300),
]);
```

Use `allSettled`: each tile degrades on its own. A 403 `PLAN_REQUIRED` on all of them means
the Read API isn't on the plan: render one quiet notice instead of four broken tiles.

Default period: last 30 days. If the panel already has a period selector, follow it
(7/30/90 days); otherwise don't add one.

## 4. Tiles

Four tiles in one row (2x2 on mobile):

| Tile | Value | Trend |
| --- | --- | --- |
| Visitors | `uniqueVisitors.value` + sparkline of daily `visitors` | `uniqueVisitors.trend` |
| Conversion | `conversionRate.value` as `2.4%` | `conversionRate.trend` |
| MRR | `revenue.mrrUsd` as currency | none (the endpoint has no comparison) |
| Subscriptions | `revenue.activeSubscriptions`, "+N trialing" subline when `activeTrials > 0` | none |

- Trend chip: `+4.2%` green / `-1.1%` red; hide when `null`. For `bounceRate` (if the user
  asks for it) the colors invert.
- Numbers: compact format (`12.4k`), currency with `Intl.NumberFormat`.
- Sparkline: use the project's chart lib if one exists; otherwise a ~40 line inline SVG
  `<polyline>`. Don't add a chart dependency for a sparkline.
- Revenue `null` → hide the two revenue tiles and let the row reflow; optional small link
  "Connect revenue in Himetrica".
- Loading skeleton per tile; errors show "—" in the tile, never crash the page.

## Done when

- [ ] Secret key server-only; loader behind the admin's auth like the rest of the page.
- [ ] Three calls in parallel, 5 min cache, per-tile degradation.
- [ ] No metric duplicated with what the panel already shows from its own DB.
- [ ] Tiles use the panel's existing card styles.
