ArchitectureOne number, one rule: what the web app and the extension must show the same

One number, one rule: what the web app and the extension must show the same

The web app and the Chrome extension read the same store record. This file is the contract for the four figures that used to differ between them. Each rule has ONE implementation on the platform and a test; the…

The web app and the Chrome extension read the same store record. This file is the contract for the four figures that used to differ between them. Each rule has ONE implementation on the platform and a test; the extension mirrors the rule, it does not re-derive it.

ConceptPlatform source of truthTest
Store-level ad spendsrc/services/algorithms/intelligence/ad-spend.ts (buildAdSpend), projected as HubStore.ad_spendad-spend.test.ts, hub-projection-ads-creatives.test.ts, field-gate.test.ts
Growth directionsrc/services/algorithms/intelligence/growth-direction.ts (resolveGrowthPct)growth-direction.test.ts
Price range and moneysrc/lib/hub/money.ts (priceRangeAmounts, formatMoney)money.test.ts, intel-detail-view.test.ts
Names of featuresdocs/architecture/ui-glossary.mdmessages/*.json parity

1. Ad spend: ad_spend

Three figures were in circulation for "the store's ad spend": the sum of Meta's per-ad ranges, a local reach x cost-per-1,000 estimate, and the hub's per-ad spend_high. A reader comparing the two products saw numbers that contradicted each other and nothing said why. There is now one object.

Where it is

ad_spend on every HubStore: GET /api/intelligence/lookup, hub/scan, hub/stores, the tracker routes, the MCP intelligence tools and the assistant tools all return the same projection through redactHubStore. The CSV export has no ad spend column.

It is gated exactly like ads_summary and the per-creative spend (the ads tier, Pro and above). Below Pro it is null, and access.locked lists ads. It is also null for an anonymized store and when the ad attribution is ambiguous (the same rule as ads_summary).

Exact JSON

Measured (the sum of disclosed ranges):

{
  "ad_spend": {
    "method": "meta_ranges_sum",
    "basis": "measured",
    "low": 4200,
    "high": 9800,
    "currency": "EUR",
    "scope": "lifetime_of_listed_ads",
    "ads_counted": 12,
    "ads_total": 37,
    "other_currency_ads": 1,
    "period_days": 61,
    "per_day_low": 68.85,
    "per_day_high": 160.66,
    "since": "2026-08-01T00:00:00.000Z",
    "observed_at": "2026-10-01T08:00:00.000Z"
  }
}

Estimated (only when NO ad discloses a spend range but some disclose reach):

{
  "ad_spend": {
    "method": "reach_x_cpm_estimate",
    "basis": "estimate",
    "low": 1350,
    "high": 2250,
    "currency": "EUR",
    "scope": "lifetime_of_listed_ads",
    "ads_counted": 2,
    "ads_total": 2,
    "other_currency_ads": 0,
    "period_days": 30,
    "per_day_low": 45,
    "per_day_high": 75,
    "cpm_assumption": {
      "amount": 9,
      "currency": "EUR",
      "per": "1000_reached",
      "source": "platform_default"
    },
    "reach_kind": "reach",
    "since": "2026-09-01T00:00:00.000Z",
    "observed_at": "2026-10-01T08:00:00.000Z"
  }
}

ad_spend is null when nothing is disclosed. Never 0: a missing figure and a zero are different statements.

Field by field

FieldMeaning
methodmeta_ranges_sum: Meta's DSA spend ranges, EU and UK only, summed. reach_x_cpm_estimate: disclosed reach times an assumed cost per 1,000.
basisThe word to print: measured or estimate. Print it next to the figure.
low, highA BAND, never a point. Lower bounds summed, upper bounds summed, no invented midpoint. low <= high. Print as a range ("EUR 4.2K to 9.8K"); if both print identically, print one value.
currencyISO 4217, from the data (measured) or from the assumption (estimate). Format it with Intl; never add a $ of your own.
scopeAlways lifetime_of_listed_ads: cumulative over the listed ads' whole run, not a monthly figure.
ads_counted, ads_totalAds inside the figure, and creatives the store holds. State the coverage ("12 of 37 ads").
other_currency_adsMeasured only. Ads that disclosed a range in another currency; they are left out, never converted.
period_daysThe delivery window the counted ads span (oldest start to latest end). null when no start date or run length is known.
per_day_low, per_day_highThe band divided by period_days. null exactly when period_days is. Quote at least 1 per day rather than 0.
cpm_assumptionEstimate only. The assumed cost per 1,000 per units. The default is 9 EUR per 1,000 people reached.
reach_kindEstimate only. reach (people, Meta's official API) or impressions (deliveries). The two are never added: the official kind wins.
sinceEarliest disclosed start among the counted ads.
observed_atWhen the live ad count was last measured.

The rules behind it (honesty doctrine)

  1. A measured sum is labelled measured; a modelled number is labelled estimate and travels with its assumption.
  2. They never mix: when ANY ad discloses a spend range, the figure is the sum of those ranges and the reach of other ads is not converted. One ad never carries two spends.
  3. The figure is a band that contains the real spend of the counted ads. It is not "the brand's total spend": spend outside the EU and UK is not disclosed.
  4. Currencies are never added together; the currency comes from the data.
  5. ad_spend_eur (the euro-only monthly proxy used by rankings) is a different, older leaf. Client surfaces read ad_spend; do not show both.

What the extension must do

  • Read store.ad_spend instead of computing a sum from creatives[] (which holds only the four most recent ads) or from ads_summary.
  • Print basis ("Measured" / "Estimate" in the glossary words), the band, the currency from the object (Intl, no hard-coded symbol), the coverage (ads_counted of ads_total) and, for an estimate, the assumption.
  • The cost-per-1,000 slider stays, as a labelled what-if: when the user moves it, recompute reach x cpm locally from ads_summary.reach_low/high and label the result "Estimate (your assumption: X per 1,000)". Never show the what-if figure under the "Measured" label, and never beside a measured band as if they were two readings of the same thing: when basis is measured, the slider has nothing to change and should be hidden.
  • Per-ad lines (creatives[].spend_*) are unchanged: they are one ad's range.

How the hub renders it

AdSpendCard in src/components/hub/intel-detail-view.tsx reads tool.intel.ad_spend. Amounts go through adSpendView and formatMoney (src/lib/hub/ad-spend-view.ts, src/lib/hub/money.ts); the words live in the hub.consistency message namespace (six locales). The per-day suffix is the translated hub.consistency.perDay, not a literal.

2. Growth: resolveGrowthPct

One rule, in src/services/algorithms/intelligence/growth-direction.ts (no server-only, imported by client components; the extension mirrors it).

resolveGrowthPct(momGrowthPct: number | null, visitsChangeRatio: number | null): number | null
  1. mom_growth_pct (ours, already percentage points) wins whenever it is a finite number. A genuine 0 is a reading ("flat"), not an absence.
  2. Otherwise visits_change_pct (the source's signed RATIO) times 100.
  3. Neither: null. Print no badge, never "0 %".
  4. Credibility: a result above 1,000 points (MAX_GROWTH_PCT, the server's own ceiling in credible-growth.ts) is excluded, never clamped. Declines have no floor.
mom_growth_pctvisits_change_pctresult
-8-0.199-8 (ours wins)
null-0.199-19.9
null0.4242
0-0.50
nullnullnull
null15null (+1,500 %: excluded)
NaN0.110
50000.1null (non-credible, not contradicted by the ratio)

The pinned test is growth-direction.test.ts; the extension's port must give the same answer for every row.

3. Price range and money

src/lib/hub/money.ts (client-safe, no imports):

  • priceDigits(amount): cents only on a non-round amount below 100. 12.9 -> 2, 99.5 -> 2, 39 -> 0, 100.5 -> 0, 180 -> 0.
  • priceRangeAmounts({ min, median, max, currency }, fmt): each of the three bounds through formatMoney with compact off, in the STORE's own currency, never converted. English examples: { 12.9, 39, 180, EUR } -> "€12.90" / "€39" / "€180"; { 350000, 620000, 890000, VND } max -> "₫890,000".
  • formatMoney(fmt, amount, currency, { compact }): Intl currency formatting; a missing or malformed currency prints the bare number (no invented symbol); compact abbreviates from 1,000 up ("€12.4K").
  • currencySymbol(code, locale): the symbol, or null when it equals the code.

fmt is format.number from next-intl, or any Intl.NumberFormat wrapper. The extension passes (n, o) => new Intl.NumberFormat(locale, o).format(n).

4. Extension changes required by the privacy page

/legal/privacy/extension describes the behaviour below. The page ships first; these are the extension changes that make it true. The tests that pin them are listed in docs/architecture/extension-privacy-claims.md.

  1. Account required. Signed out, show only the sign-in screen and request nothing about the page. The only request is the session check (/api/usage, /api/me), which carries no website address.
  2. Nothing about a page is read or sent without a click. The click is the toolbar icon, or Auto-open (off by default), which counts as that click. After it: look the store's domain up on BoostEcom; if the platform does not know it, ask BoostEcom to add it (domain only, signed in); BoostEcom's servers collect it in the background. Nothing read on the page is sent.
  3. Only local platform detection runs on page load (Shopify, Webflow, Framer, Next.js and others), with no network request, to set the logo on the toolbar icon.
  4. Native side panel. While open, it follows navigation: local detection first, and only when a supported platform is detected is the domain looked up on BoostEcom. Closed, nothing.
  5. Remove the browser-side Meta Ads Library read. No facebook.com Ads Library request, no optional facebook.com permission request, no skp_ads_probe:* cache keys. Ads figures come from the platform (active_creatives, ads_summary, ad_spend, creatives).
  6. Favicons. Google's favicon service only for app and pixel vendor domains from the fixed table (APP_DOMAINS). Similar stores get no request to Google: draw a bundled initial or glyph. Remove the favicon URL the worker builds for similar stores.