---
name: himetrica-user-journey
description: Add a Himetrica "journey" card to an admin panel's user detail page showing how that user found the product (first-touch source, UTM, landing page), sessions, top pages and events, revenue and subscription status, with a link to the full profile in Himetrica. Use when the user wants to see a customer's acquisition source, activity history, behavior, or Himetrica data inside their own admin/back-office/dashboard user page.
---

# Himetrica user journey card

Goal: on the admin panel's page for one user, show what the app's database can't know:
where the person came from, what they did, and whether they pay. Data comes from the
Himetrica Read API, looked up by the app's own user id.

Requires `himetrica-install` (tracker + `identify` with `userId` = the app's user id).
If `identify` is not wired, do that first, otherwise every lookup returns 404.

## 0. Gather

- Env (server only): `HIMETRICA_SECRET_KEY` (`hm_sk_...`, Himetrica > project settings >
  API) and `HIMETRICA_PROJECT_ID` (`GET https://app.himetrica.com/api/v1/me` with the key
  returns it).
- The admin's user detail page, how it gets the user (id + email), and the admin auth
  guard used by its other server routes.
- The panel's UI kit (shadcn, MUI, Tailwind only...). Build the card with it; don't add
  a new component library.

## 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. API facts

`GET /visitors/{ref}` accepts any of: the app's `userId`, an email, Himetrica's visitor
row id, or the tracker visitorId. Pass `encodeURIComponent(user.id)`; if it 404s and you
have an email, try the email (users identified before `userId` was wired only have email).

- 404 `NOT_FOUND`: user never identified (signed up before the tracker, or blocked it).
  Render an empty state, not an error.
- 403 `PLAN_REQUIRED`: Read API needs a paid Himetrica plan. Render a short notice.
- 429: this endpoint is limited to **10 requests/min per key**. Cache per user (5 min) and
  load the card only on the detail page, never in a list.

Response `data` (only the fields worth using; there are more):

```ts
type HimetricaVisitor = {
  id: string;                         // Himetrica row id (for the deep link)
  userId: string | null; email: string | null; name: string | null;
  country: string | null; city: string | null;
  device: string | null; os: string | null; browser: string | null;
  firstSeenAt: string; lastSeenAt: string;
  engagementLevel: string | null;     // e.g. "active", "power_user"
  lifecycleStage: string | null;
  sessions: Array<{                   // ALL sessions, newest first; last item = first touch
    referrerDomain: string;           // "" = direct
    utmSource: string; utmMedium: string; utmCampaign: string;
    landingPage: string; exitPage: string;
    pageCount: number; duration: number; startedAt: string;
  }>;
  stats: {
    totalSessions: number; totalPageViews: number;
    avgSessionDuration: number;       // seconds
    topPages: Array<{ path: string; count: number }>;
    topEvents: Array<{ eventName: string; count: number }>; // names starting with "$" are system events
    churnScore: number | null;        // paying users only, higher = riskier
  };
  totalRevenueUsd: number; activeSubscriptions: number;
  isTrialing: boolean; isChurned: boolean; lastPaymentAt?: string;
};
```

Deep link to the profile in Himetrica: `GET https://app.himetrica.com/api/v1/dashboard-url`
(key only, no project in the path) returns
`{ projectId, dashboardUrl, visitorUrlTemplate }`; replace `{visitorId}` in the template
with the visitor's `id`. It never changes for a key: fetch once and cache for hours.

Optional, lazy (behind a "Show events" button, same 10/min limit):
`GET /visitors/{ref}/events?limit=10` returns
`{ events: Array<{ eventName; properties; path; createdAt }>, pagination }`.

## 3. Admin route: slim DTO

Create a server route in the panel's style, e.g. `GET /api/admin/users/:id/himetrica`
(or a server action / loader). It MUST run the same admin auth check as the panel's other
admin routes. Map the payload to the slim DTO below and never forward the raw object to
the browser: it carries more than the card needs (metadata, notes, visit patterns).

```ts
const v = await himetrica<HimetricaVisitor>(`/visitors/${encodeURIComponent(ref)}`, {}, 300);
const links = await dashboardLinks(); // see below
const first = v.sessions.at(-1);
return {
  firstSeenAt: v.firstSeenAt,
  lastSeenAt: v.lastSeenAt,
  firstTouch: first && {
    source: first.referrerDomain || "Direct",
    utm: [first.utmSource, first.utmMedium, first.utmCampaign].filter(Boolean).join(" / ") || null,
    landingPage: first.landingPage,
  },
  location: [v.city, v.country].filter(Boolean).join(", ") || null,
  device: [v.device, v.os ?? v.browser].filter(Boolean).join(" · ") || null,
  sessions: v.stats.totalSessions,
  pageViews: v.stats.totalPageViews,
  avgSessionSeconds: v.stats.avgSessionDuration,
  topPages: v.stats.topPages.slice(0, 5),
  topEvents: v.stats.topEvents.filter((e) => !e.eventName.startsWith("$")).slice(0, 5),
  engagement: v.engagementLevel,
  revenue: { totalUsd: v.totalRevenueUsd, activeSubscriptions: v.activeSubscriptions,
             trialing: v.isTrialing, churned: v.isChurned, lastPaymentAt: v.lastPaymentAt ?? null },
  churnScore: v.stats.churnScore,
  himetricaUrl: links?.visitorUrlTemplate.replace("{visitorId}", v.id) ?? null,
};
```

`/dashboard-url` is outside `/projects/{id}`, so call it with a plain fetch (same
`X-API-Key` header) and keep it in memory:

```ts
let linksCache: { at: number; value: { visitorUrlTemplate: string } } | null = null;

async function dashboardLinks() {
  if (linksCache && Date.now() - linksCache.at < 6 * 3600_000) return linksCache.value;
  try {
    const res = await fetch("https://app.himetrica.com/api/v1/dashboard-url", {
      headers: { "X-API-Key": process.env.HIMETRICA_SECRET_KEY! },
    });
    if (!res.ok) return null;
    linksCache = { at: Date.now(), value: (await res.json()).data };
    return linksCache.value;
  } catch {
    return null;
  }
}
```

Map `HimetricaError` 404 → `{ found: false }`, 403 → `{ plan: false }`, anything else →
500 without leaking the upstream message.

## 4. Card

One card on the user detail page, loaded client-side (or streamed) so a slow/failed
Himetrica call never blocks the page. Content, top to bottom:

1. **How they found you**: source (with `https://www.google.com/s2/favicons?domain=<source>`
   favicon when not Direct), UTM line, landing page, "first seen X days ago".
2. **Activity**: sessions, page views, avg session length (format seconds as `2m 07s`),
   last seen (relative), engagement level as a small badge.
3. **Top pages / top events**: two short lists, max 5 each.
4. **Revenue**: total paid, active subscription / trialing / churned badge, last payment.
   Hide the block when `totalUsd === 0 && !trialing`.
5. **Footer link** "Open in Himetrica" when `himetricaUrl` is set (new tab).

States: loading skeleton; not found ("Not tracked yet: this user hasn't been seen by
Himetrica since the tracker was installed"); plan required; error ("Couldn't load
Himetrica data").

## Done when

- [ ] Secret key only on the server; route behind the admin guard; slim DTO only.
- [ ] Lookup by the app's user id with email fallback; 404 is an empty state.
- [ ] Cached 5 min per user; nothing calls `/visitors/{ref}` from a list/table.
- [ ] Card matches the panel's existing UI kit.
