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:
| Brand | Network total | Fleet size | Typical store, daily visits |
|---|---|---|---|
| Publix Super Markets | 1,025M | 1,424 | 4,052 |
| Kroger | 897M | 1,260 | 3,982 |
| Albertsons | 245M | 378 | 3,630 |
| H-E-B | 238M | 340 | 3,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:
| Brand | Stores, 2024 to 2025 | Fleet total change | Same-store change | Same-store count | Share of stores growing |
|---|---|---|---|---|---|
| Kroger | 1,256 to 1,260 | +0.1% | −0.3% | 1,251 | 30% |
| Publix Super Markets | 1,386 to 1,424 | +0.8% | −0.7% | 1,383 | 27% |
| H-E-B | 336 to 340 | −0.5% | −1.3% | 336 | 13% |
| Albertsons | 380 to 378 | −6.0% | −5.7% | 375 | 20% |
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:
| Brand | 1st | 2nd | 3rd |
|---|---|---|---|
| Kroger | City Hopefuls 11.0% | Wealthy Suburban Families 11.0% | Upper Suburban Diverse Families 10.3% |
| Publix Super Markets | Upper Suburban Diverse Families 13.5% | Wealthy Suburban Families 10.3% | Young Urban Singles 8.3% |
| Albertsons | Wealthy Suburban Families 13.6% | Near-Urban Diverse Families 10.7% | Young Urban Singles 10.4% |
| H-E-B | Melting 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.