Back
GuidesBrand ComparisonGrocery brand comparison
Node.js
USA

Grocery brand comparison

This example shows how to run a brand comparison for four US grocery chains: Kroger, Publix Super Markets, Albertsons and H-E-B.

For this we use our US Places metrics, queried from Node.js 20 or later with nothing beyond the built-in fetch. Metric rows embed the location's descriptive fields (brands, city, region, us_cbsa, top_category), so every step filters the metric datasets directly, with no join against a places directory. The helpers below are all the setup the example needs:

const API = "https://api.pine59.com/v3/datasets";
const HEADERS = {
  Authorization: `Bearer ${process.env.PINE59_API_KEY}`,
  "Content-Type": "application/json",
};

async function post(path, body) {
  const res = await fetch(`${API}/${path}`, {
    method: "POST", headers: HEADERS, body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
  return res.json();
}

const runQuery = async (dataset, body) =>
  ((await post(`${dataset}:runQuery`, body)).records ?? []).map((r) => r.fields);
const searchValues = async (dataset, field, term) =>
  (await post(`${dataset}:searchFieldValues`, { field: { name: field }, term })).values ?? [];

1. Verify the brand names

Brand values must match the catalog exactly: it is Publix Super Markets, not Publix, and ALDI, not Aldi. A misspelled value does not error, it silently returns zero rows, so we check each brand once before filtering on it:

for (const term of ["kroger", "publix", "albertsons", "h-e-b"]) {
  console.log(term, await searchValues("visitation_usa_places.foot_traffic_month", "brands", term));
}

const BRANDS = ["Kroger", "Publix Super Markets", "Albertsons", "H-E-B"];

The search for publix returns Publix Super Markets, Publix Pharmacy, Publix Liquor Stores and Publix Distribution: the supermarket banner is a separate brand from its pharmacies and warehouses, which is what we want for a store comparison.

2. Overall vs. typical performance

One grouped query gives the two numbers the concept guide keeps apart, plus the fleet size that explains the gap between them: the network total (visits_sum summed), the fleet (distinct location_id), and the typical store's footfall (visits_p50, the median daily visits, averaged across the fleet):

const H1_2025 = [
  { fieldName: "observation_start_date", operator: ">=", value: "2025-01-01" },
  { fieldName: "observation_start_date", operator: "<",  value: "2025-07-01" },
];

const levels = await runQuery("visitation_usa_places.foot_traffic_month", {
  fields: [
    { name: "brands" },
    { name: "visits_sum",  aggregation: "SUM" },
    { name: "visits_p50",  aggregation: "AVG" },
    { name: "location_id", aggregation: "COUNT_DISTINCT" },
  ],
  filters: [{ fieldName: "brands", operator: "in", values: BRANDS }, ...H1_2025],
  groupBy: [{ fieldName: "brands" }],
  options: { orderBy: { fieldName: "visits_sum", direction: "DESC" } },
});

console.table(levels.map((r) => ({
  brand: r.brands,
  networkTotalM: Math.round(r.visits_sum_SUM / 1e6),
  fleet: Number(r.location_id_COUNT_DISTINCT),
  typicalStoreDaily: Math.round(r.visits_p50_AVG),
})));

Result, Jan–Jun 2025:

BrandNetwork totalFleet sizeTypical store, daily visits
Publix Super Markets1,025M1,4244,052
Kroger897M1,2603,982
Albertsons245M3783,630
H-E-B238M3403,905

How to read it: the network total rewards fleet size, so the interesting column is the last one. H-E-B's typical store pulls Kroger-level footfall on a fleet a quarter the size, and beats Albertsons' typical store while trailing it on total.

To scope regionally, add a filter on region (2-letter state) or us_cbsa (metro). To build the set by category instead of names, filter top_category, verified with searchValues first.

3. Per-store development

Development needs two windows a year apart on the same months, so season and market conditions drop out of the difference. We pull visits per store for each window, join the two on location_id, and compute three things: the fleet total's change, the same-store change on locations present in both windows, and the share of those stores that grew:

const perStore = async (from, to) => runQuery("visitation_usa_places.foot_traffic_month", {
  fields: [
    { name: "brands" },
    { name: "location_id" },
    { name: "visits_sum", aggregation: "SUM" },
  ],
  filters: [
    { fieldName: "brands", operator: "in", values: BRANDS },
    { fieldName: "observation_start_date", operator: ">=", value: from },
    { fieldName: "observation_start_date", operator: "<",  value: to },
  ],
  groupBy: [{ fieldName: "brands" }, { fieldName: "location_id" }],
  pageSize: 5000,
});

const prev = await perStore("2024-01-01", "2024-07-01");
const curr = await perStore("2025-01-01", "2025-07-01");

const byStore = (rows) => new Map(rows.map((r) => [r.location_id, { brand: r.brands, visits: Number(r.visits_sum_SUM) }]));
const a = byStore(prev), b = byStore(curr);

console.table(BRANDS.map((brand) => {
  const sum = (m) => [...m.values()].filter((s) => s.brand === brand).reduce((t, s) => t + s.visits, 0);
  const both = [...a.keys()].filter((id) => b.has(id) && a.get(id).brand === brand);
  const sa = both.reduce((t, id) => t + a.get(id).visits, 0);
  const sb = both.reduce((t, id) => t + b.get(id).visits, 0);
  const growing = both.filter((id) => b.get(id).visits > a.get(id).visits).length;
  const pct = (x, y) => `${((x / y - 1) * 100).toFixed(1)}%`;
  const stores = (m) => [...m.values()].filter((s) => s.brand === brand).length;
  return { brand, stores: `${stores(a)} to ${stores(b)}`, fleetChange: pct(sum(b), sum(a)),
           sameStoreChange: pct(sb, sa), sameStoreCount: both.length,
           shareGrowing: `${Math.round(growing / both.length * 100)}%` };
}));

Result, Jan–Jun 2025 vs. Jan–Jun 2024:

BrandStores, 2024 to 2025Fleet total changeSame-store changeSame-store countShare of stores growing
Kroger1,256 to 1,260+0.1%−0.3%1,25130%
Publix Super Markets1,386 to 1,424+0.8%−0.7%1,38327%
H-E-B336 to 340−0.5%−1.3%33613%
Albertsons380 to 378−6.0%−5.7%37520%

How to read it: three brands are flat within a point on both columns; Albertsons is the outlier, down close to 6% on a same-store basis. That is a store-level trend, not a fleet effect: its location count barely moved (380 to 378). Where the two columns part, the gap is the fleet: Publix's total grew while its same-store visits eased slightly, so the growth is openings (1,386 to 1,424 locations). The last column adds a read the averages hide: H-E-B's small decline is broad (only 13% of stores grew), while Kroger's near-zero average is a wider mix of winners and losers (30% growing).

4. Audience fingerprint

spatialai_visitor_profile returns each location's visitors as lifestyle-persona shares (segment_family, people_fraction), quarterly. AVG weights every location equally, the typical store's visitor base. Grouping by brand and segment gives all four fingerprints in one query per window; the same window a year earlier gives the drift:

const fingerprint = async (from, to) => runQuery("visitation_usa_places.spatialai_visitor_profile", {
  fields: [
    { name: "brands" },
    { name: "segment_family" },
    { name: "people_fraction", aggregation: "AVG" },
  ],
  filters: [
    { fieldName: "brands", operator: "in", values: BRANDS },
    { fieldName: "observation_start_date", operator: ">=", value: from },
    { fieldName: "observation_start_date", operator: "<",  value: to },
  ],
  groupBy: [{ fieldName: "brands" }, { fieldName: "segment_family" }],
});

const profile = await fingerprint("2025-01-01", "2026-01-01");
const profilePrev = await fingerprint("2024-01-01", "2025-01-01");

for (const brand of BRANDS) {
  const rows = profile.filter((r) => r.brands === brand);
  const top = [...rows]
    .sort((x, y) => y.people_fraction_AVG - x.people_fraction_AVG)
    .slice(0, 3)
    .map((r) => `${r.segment_family} ${(r.people_fraction_AVG * 100).toFixed(1)}%`);
  // largest year-on-year move of any segment, in percentage points
  const drift = Math.max(...rows.map((r) => {
    const prev = profilePrev.find((q) => q.brands === brand && q.segment_family === r.segment_family);
    return Math.abs((r.people_fraction_AVG - (prev?.people_fraction_AVG ?? 0)) * 100);
  }));
  console.log(brand.padEnd(22), top.join(" | "), ` | largest move vs. 2024: ${drift.toFixed(1)} pt`);
}

Result, top three persona segments per brand, 2025:

Brand1st2nd3rd
KrogerCity Hopefuls 11.0%Wealthy Suburban Families 11.0%Upper Suburban Diverse Families 10.3%
Publix Super MarketsUpper Suburban Diverse Families 13.5%Wealthy Suburban Families 10.3%Young Urban Singles 8.3%
AlbertsonsWealthy Suburban Families 13.6%Near-Urban Diverse Families 10.7%Young Urban Singles 10.4%
H-E-BMelting Pot Families 25.8%Young Urban Singles 10.8%Wealthy Suburban Families 10.1%

How to read it: three of the four share a suburban-family core and differ in the second tier. H-E-B is the outlier: one segment at 26%, nearly double any other brand's top share, a fingerprint of its Texas footprint as much as its concept. Against 2024, no segment moves more than a point at any brand (the largest is H-E-B's Melting Pot Families, 0.7 points). Fingerprints are stable signatures, which is exactly why a slow drift is worth noticing when it happens: add observation_start_date to groupBy to watch the shares quarter by quarter.

census_visitor_demographics works the same way with age, income, education, gender and race bands (people_fraction_age_30_39, people_fraction_income_125k_and_above, and so on). For a visitor-weighted fingerprint instead of the typical-store view, pull visits_sum alongside and weight the fractions yourself.

Did you find what you were looking for?