# Proof and provenance

A case study is only as good as the numbers in it, so every number carries
where it came from, and the kit decides what each one may claim. This page
covers that model and the functions that apply it: what to run before you
render, the line a number wears, which number a card leads with, and which
live analytics changes are worth showing.

Everything here is pure (no React, no network, no server guard) and imports
from `@sonordev/agency-site-kit/portfolio`.

## Where a number came from

Every KPI tile (`PortfolioKPI`) and results card (`PortfolioResultItem`) has a
`source`:

| Source      | Meaning                                                     | What it must carry                                           |
| ----------- | ----------------------------------------------------------- | ------------------------------------------------------------ |
| `measured`  | Counted from real data: analytics, search, a Lighthouse run | Nothing more                                                 |
| `reported`  | The client told you                                         | `reportedBy`: who, where they work, and the day they said it |
| `estimated` | A projection or an estimate                                 | Nothing more, but it can never lead a card                   |

`reportedBy` is a `MetricAttribution`:

```ts
{
  name: 'Dana Reyes',               // required
  role: 'Director of Operations',   // optional
  organization: 'Northwind Engineering', // required
  date: '2026-09-25',               // required, YYYY-MM-DD
}
```

A number with no `source` at all is older data. It displays without a label
and can't lead a card. The model is defined once, in
`@sonordev/site-kit/portfolio/contract`, and Sonor applies the same rules
when it writes a case study.

## Before you render: `curatePortfolioProof`

```ts
import { curatePortfolioProof } from '@sonordev/agency-site-kit/portfolio';

const curated = curatePortfolioProof(item);
```

It applies the display policy to a `PortfolioItemFull` and returns a new item
(the one you fetched is never changed):

- **Hero KPIs collapse Lighthouse tiles.** A Lighthouse tile is one whose
  label mentions Lighthouse. When the hero carries two or more, they become
  one: four or more, all at 100, turn into a single
  `Lighthouse, all 4 categories` tile; otherwise the Performance tile (or the
  first Lighthouse tile, if none is Performance) stands in for the set.
  Business numbers keep their order and come first.
- **Results cards stop repeating scores.** When the case study has a
  Performance section, a results card that only restates a Lighthouse score
  is dropped (its title mentions a score, and it mentions Lighthouse or
  carries an `x/100` figure). If every card is one of those, the first card
  stays, so the section is never empty.
- Every other section passes through untouched.

The two halves are exported on their own too, if you're building from
something other than a whole item:

| Function                                          | Returns                                                                                            |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `curateHeroKpis(kpis)`                            | The KPI list with Lighthouse tiles collapsed. Fewer than two Lighthouse tiles come back unchanged. |
| `curateResultItems(items, hasPerformanceSection)` | The results cards with Lighthouse restatements dropped, when `hasPerformanceSection` is true.      |

## The line a number wears: `metricNote`

```ts
metricNote(metric, 'short' | 'full'): string | null
```

Render it under every number you didn't measure. It's what keeps a client's
figure from reading as something you counted.

| The number                  | `'short'`                   | `'full'`                                                                                    |
| --------------------------- | --------------------------- | ------------------------------------------------------------------------------------------- |
| Reported, with `reportedBy` | `per Northwind Engineering` | `Reported by Dana Reyes, Director of Operations, Northwind Engineering, September 25, 2026` |
| Reported, nobody named      | `Client-reported`           | `Client-reported`                                                                           |
| Estimated                   | `Estimated`                 | `Estimated`                                                                                 |
| Measured, or no source      | `null`                      | `null`                                                                                      |

Use `'short'` on a card or a hero tile and `'full'` under a results card.

```tsx
import { formatMetricValue, metricNote } from '@sonordev/agency-site-kit/portfolio';

{results.items.map((r) => {
  const note = metricNote(r, 'full');
  return (
    <li key={r.title}>
      {r.metric ? <strong>{formatMetricValue(r.metric)}</strong> : null}
      <h3>{r.title}</h3>
      {note ? <p className="note">{note}</p> : null}
    </li>
  );
})}
```

`metricCredit(metric, form)` is the credit on its own: the same strings as the
first row above, and `null` for everything else.

## The number a card leads with: `headlineKpi`

```ts
headlineKpi(kpis: PortfolioKPI[] | null | undefined): HeadlineKpi | null
```

It picks the first KPI that may headline, in the item's own order, after the
Lighthouse tiles collapse: a measured number, or a reported one with someone
named behind it. Never an estimate and never an unlabelled number. It returns
`null` when there isn't one, so show the card without a figure.

```ts
interface HeadlineKpi {
  value: string;                      // formatted, e.g. "$1,000,000"
  label: string;
  source: 'measured' | 'reported';
  credit: string | null;              // "per Northwind Engineering" on a reported number
  kpi: PortfolioKPI;                  // the tile it came from
}
```

```tsx
import { headlineKpi } from '@sonordev/agency-site-kit/portfolio';

const kpi = headlineKpi(item.kpis);

{kpi ? (
  <p>
    <strong>{kpi.value}</strong> {kpi.label}
    {kpi.credit ? <span> ({kpi.credit})</span> : null}
  </p>
) : null}
```

It works on list items (`PortfolioItem.kpis`), so the index needs no extra
fetch.

## Printing a figure: `formatMetricValue`

```ts
formatMetricValue({ value, prefix?, suffix? }): string
```

Prefix, the number with thousands separators (up to two decimal places), then
the suffix:

| Input                                    | Output       |
| ---------------------------------------- | ------------ |
| `{ value: 1000000, prefix: '$' }`        | `$1,000,000` |
| `{ value: 1, prefix: '$', suffix: 'M' }` | `$1M`        |
| `{ value: 12500 }`                       | `12,500`     |
| `{ value: 98, suffix: '/100' }`          | `98/100`     |

A string value prints as stored. It takes a KPI, a results card's `metric`,
or anything else with that shape.

## Live analytics changes: `gateMetricsDeltas`

`PortfolioItemFull.metricsDelta` compares a project's live numbers with its
baseline. Not every change is worth publishing, so run them through the gate
first:

```ts
import { gateMetricsDeltas } from '@sonordev/agency-site-kit/portfolio';

const wins = gateMetricsDeltas(item.metricsDelta);
```

A change passes when all of these hold:

- It improved. `direction` is `'up'` for an improvement even on
  lower-is-better metrics like bounce rate, so a drop in bounce rate reads
  `'up'`.
- It moved by 10% or more (`deltaPercent`).
- If it's a bounce rate, the current value is 70 or lower. A bounce rate that
  went from 100 to 99.7 improved, and it still isn't something to show off.

It returns an empty array for `null`, `undefined` or an empty list.

## The contract, re-exported

These come from `@sonordev/site-kit/portfolio/contract` and are re-exported,
so one import covers everything:

| Export                              | What it does                                                                                                |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `METRIC_SOURCES`                    | `['measured', 'reported', 'estimated']`                                                                     |
| `MetricSource`, `MetricAttribution` | The types above (from `/portfolio` and the root entry)                                                      |
| `isHeadlineMetric(metric)`          | True for a measured number, or a reported one with a valid `reportedBy`                                     |
| `metricSourceLabel(source)`         | `Measured`, `Client-reported`, `Estimated`, or `null` for a missing or unknown source                       |
| `metricSourceProblem(metric)`       | What's wrong with a number's provenance as a sentence, or `null`. Handy for flagging bad data in a preview. |
| `formatAttribution(by)`             | The full credit sentence                                                                                    |
| `shortAttribution(by)`              | `per <organization>`                                                                                        |

If your design badges every number, measured ones included, read
`metricSourceLabel` for the badge and keep `metricNote` for the credit.

## Showing the evidence

Sonor can publish the evidence behind a case study's numbers: what was
counted, over which window, against which baseline, where it came from, and
what it doesn't prove. Fetch it on the server with `getProofRecords`:

```tsx
import { formatIsoDate } from '@sonordev/agency-site-kit/portfolio';
import { getProofRecords } from '@sonordev/agency-site-kit/portfolio/server';

const records = await getProofRecords(item.slug, { site: 'youragency.com' }).catch(() => []);

{records.length ? (
  <section>
    <h2>How we measured this</h2>
    <ul>
      {records.map((r) => (
        <li key={r.id}>
          <strong>
            {r.result_value} {r.unit}
          </strong>{' '}
          {r.definition}, {formatIsoDate(r.window_start)} to {formatIsoDate(r.window_end)}.{' '}
          <a href={r.source_url}>{r.source_label}</a>. {r.limitations}
        </li>
      ))}
    </ul>
  </section>
) : null}
```

`site` is your own site's host: records are published per site, so the same
case study can carry different evidence on two sites. An empty list is a
healthy answer. Like every fetcher, it throws when Sonor can't be reached; the
`.catch` above renders the page without its evidence instead. See
[Fetching](https://sonor.dev/agency-site-kit/fetching) for the details and [Types](https://sonor.dev/agency-site-kit/types) for every
`ProofRecord` field.
