docs
    agency-site-kit: Proof and provenance
    v0.11.1.md

    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:

    SourceMeaningWhat it must carry
    measuredCounted from real data: analytics, search, a Lighthouse runNothing more
    reportedThe client told youreportedBy: who, where they work, and the day they said it
    estimatedA projection or an estimateNothing more, but it can never lead a card

    reportedBy is a MetricAttribution:

    {
      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

    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:

    FunctionReturns
    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

    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 reportedByper Northwind EngineeringReported by Dana Reyes, Director of Operations, Northwind Engineering, September 25, 2026
    Reported, nobody namedClient-reportedClient-reported
    EstimatedEstimatedEstimated
    Measured, or no sourcenullnull

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

    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

    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.

    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
    }
    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

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

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

    InputOutput
    { 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:

    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:

    ExportWhat it does
    METRIC_SOURCES['measured', 'reported', 'estimated']
    MetricSource, MetricAttributionThe 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:

    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 for the details and Types for every ProofRecord field.