Product Area Interest
Also known as: Category Affinity, Category of Interest, Product Category Affinity, Preferred Product Category, Top Category Interest
Product Area Interest is the user-level category affinity attribute: the top-level product category (item_category, department) in which a shopper has recorded the most qualifying product interactions (view_item, add_to_cart, purchase) over a rolling 30-day lookback. It turns item-scoped ecommerce behaviour into one stable, segmentable value, so merchandising, CRM and paid media teams can target, personalise and size audiences by the department each shopper is actually engaging with rather than by the last page viewed. Unlike item-category, which is the product attribute carried on each event, Product Area Interest is a shopper-level value derived from it.
Business Use Case
1️⃣ CRM / Lifecycle Marketing: each morning, when a shopper with no purchase in the last 30 days holds a Product Area Interest value, the team enrols them in the matching department browse-reminder journey, featuring that department's best sellers and new arrivals instead of the generic newsletter. 2️⃣ E-commerce Product (Personalisation): on every homepage and category-landing request from a returning shopper whose value is set, the team reorders the hero banner and the recommendation carousel to lead with that department, measuring lift against a 10% holdout that sees the default layout. 3️⃣ Paid Media: during the daily audience sync, the team pushes one remarketing list per Product Area Interest value to Google Ads and Meta with department-specific creative, and excludes shoppers who purchased in that department in the last 14 days to stop spend on recent buyers. 4️⃣ Merchandising Planning: at the monthly range review, planners compare each department's share of users by Product Area Interest with its share of revenue; any department whose interest share exceeds revenue share by more than 5 points is flagged for an assortment depth, pricing and in-stock review.
Business Criticality
Medium
Journey / Navigation Area
Discovery
Owning Function
Merchandising
Scope
User
Calculation Notes
1️⃣ Qualifying interactions: only the unified item-scoped events view_item, add_to_cart and purchase count; each item in the items array is one interaction. view_item is counted once per item_id per session (30-minute inactivity timeout) so PDP reloads, variant switches and back-navigation do not inflate a category; add_to_cart and purchase count once per item_id per event. view_item_list, select_item, remove_from_cart and add_to_wishlist are excluded. 2️⃣ Taxonomy level: the value is item_category (level 1, department) resolved from the product catalog master by item_id, not the raw data-layer string, so category renames and merchant re-trees apply retroactively; values are trimmed and case-normalised. Levels 2–5 (item_category2–5) are out of scope and remain available as separate breakdowns. 3️⃣ Lookback and refresh: a rolling 30-day window ending at the close of the previous day in the property / report-suite reporting time zone (the reporting_tz parameter at the top of both queries), recalculated daily; the attribute carries the value as of the last refresh and is not rewritten on historical hits, so trend reports reflect the value each user held on each date. 4️⃣ Ranking and tie-breaks: the category with the highest qualifying interaction count wins; on a tie, the category containing a purchase wins, then the category containing an add_to_cart, then the category with the most recent interaction timestamp, and finally alphabetical order so the output is deterministic. 5️⃣ Unknown handling: a user needs at least 2 mapped qualifying interactions (interactions whose item_id resolves to a catalog category) in the window to receive a value, so unmapped interactions never count toward the threshold; users below the threshold, and interactions whose item_id has no catalog category, resolve to (not set). (not set) never wins the ranking while any mapped category has interactions. 6️⃣ Exclusions: internal, staff and known bot traffic are filtered before aggregation; test orders and zero-value replacement orders are removed; purchases later refunded still count, because the attribute measures interest rather than net revenue. Gift-card and service SKUs (warranty, shipping protection) are excluded from qualifying interactions. 7️⃣ Identity and consent: computed per user_id where the shopper is authenticated (stitching devices), otherwise per user_pseudo_id; only events collected with analytics consent granted are used, and when the value is activated for advertising audiences ad_personalization (GA4 export proxy: privacy_info.ads_storage; AEP: consents.share) consent is additionally required, since an inferred interest joined to a known customer counts as profiling under GDPR / CPRA.
Technical Details
Events
| Platform | Event |
|---|---|
| Google Analytics 4 | view_item |
| Adobe | productView |
Data Mappings
W3 Data Layer
{
"page": {
"pageInfo": {
"pageID": "pdp:SKU-48213",
"pageName": "product:footwear:running-shoes:trail-runner-gtx",
"destinationURL": "https://www.example-shop.com/p/trail-runner-gtx/SKU-48213"
},
"category": {
"pageType": "product",
"primaryCategory": "Footwear",
"subCategory1": "Running Shoes"
}
},
"product": [
{
"productInfo": {
"productID": "SKU-48213",
"productName": "Trail Runner GTX",
"manufacturer": "Northpeak"
},
"category": {
"primaryCategory": "Footwear",
"subCategory1": "Running Shoes"
},
"price": {
"basePrice": 129,
"currency": "USD"
},
"quantity": 1
}
],
"event": [
{
"eventInfo": {
"eventName": "productView",
"eventAction": "view_item",
"timeStamp": "2026-09-14T15:42:07.000Z"
}
}
],
"user": [
{
"profile": [
{
"profileInfo": {
"profileID": "9f2c7b1e4a6d8053c1e7f94b2a0d6e3857c4b19f0a2e6d7c3b8f5a1e9d4c2b70"
},
"attributes": {
"loginStatus": "authenticated",
"idHashAlgorithm": "SHA-256"
}
}
]
}
],
"privacy": {
"accessCategories": [
{
"categoryName": "analytics",
"domains": [
"www.example-shop.com"
]
},
{
"categoryName": "ad_personalization",
"domains": [
"www.example-shop.com"
]
}
]
}
}GA4 Data Layer
{
"event": "view_item",
"user_id": "9f2c7b1e4a6d8053c1e7f94b2a0d6e3857c4b19f0a2e6d7c3b8f5a1e9d4c2b70",
"page_type": "product",
"ecommerce": {
"currency": "USD",
"value": 129,
"items": [
{
"item_id": "SKU-48213",
"item_name": "Trail Runner GTX",
"item_brand": "Northpeak",
"item_category": "Footwear",
"item_category2": "Running Shoes",
"item_variant": "Grey / US 10",
"price": 129,
"quantity": 1,
"index": 0
}
]
}
}Adobe Client Data Layer
{
"event": "productView",
"eventInfo": {
"path": "product.SKU-48213"
},
"page": {
"pageName": "product:footwear:running-shoes:trail-runner-gtx",
"pageType": "product",
"siteSection": "Footwear"
},
"product": {
"SKU-48213": {
"productID": "SKU-48213",
"productName": "Trail Runner GTX",
"brand": "Northpeak",
"productCategory": "Footwear",
"productSubCategory": "Running Shoes",
"variant": "Grey / US 10",
"price": 129,
"currency": "USD",
"quantity": 1
}
},
"user": {
"authState": "authenticated",
"crmIdHashed": "9f2c7b1e4a6d8053c1e7f94b2a0d6e3857c4b19f0a2e6d7c3b8f5a1e9d4c2b70"
},
"consent": {
"analytics": "granted",
"adPersonalization": "granted"
}
}XDM Mapping
{
"eventType": "commerce.productViews",
"timestamp": "2026-09-14T15:42:07.000Z",
"identityMap": {
"ECID": [
{
"id": "45729385616230459802281456632901448113",
"authenticatedState": "ambiguous",
"primary": true
}
],
"CRMID": [
{
"id": "9f2c7b1e4a6d8053c1e7f94b2a0d6e3857c4b19f0a2e6d7c3b8f5a1e9d4c2b70",
"authenticatedState": "authenticated",
"primary": false
}
]
},
"web": {
"webPageDetails": {
"name": "product:footwear:running-shoes:trail-runner-gtx",
"URL": "https://www.example-shop.com/p/trail-runner-gtx/SKU-48213",
"siteSection": "Footwear"
}
},
"commerce": {
"productViews": {
"value": 1
}
},
"productListItems": [
{
"SKU": "SKU-48213",
"name": "Trail Runner GTX",
"productCategories": [
{
"categoryID": "footwear",
"categoryName": "Footwear",
"hierarchy": "Footwear/Running Shoes"
}
],
"priceTotal": 129,
"currencyCode": "USD",
"quantity": 1
}
],
"consents": {
"collect": {
"val": "y"
},
"personalize": {
"content": {
"val": "y"
}
},
"share": {
"val": "y"
}
}
}SQL Queries
Google Analytics 4 (BigQuery)
-- ==========================================
-- 1. GOOGLE BIGQUERY SQL
-- ==========================================
-- Product Area Interest (Category Affinity): one row per user per snapshot date.
-- Window: snapshot_date - 29 days .. snapshot_date (30 days, property reporting time zone); the daily run uses snapshot_date = yesterday.
-- Backfill: widen the params range and move both _TABLE_SUFFIX lower bounds to snapshot_start - 29 days.
-- Ad consent: ad_activation_eligible uses privacy_info.ads_storage as the proxy for ad_personalization, because the GA4 BigQuery export does not carry ad_personalization; where a consent-log table exists, a join on it replaces this proxy.
-- Consent data absent: properties without Consent Mode have NULL privacy_info fields and return no rows under the strict analytics_storage = 'Yes' filter.
-- reporting_tz must equal the GA4 property reporting time zone (calculation note 3).
DECLARE reporting_tz STRING DEFAULT 'America/New_York';
WITH params AS (
SELECT
DATE_SUB(CURRENT_DATE(reporting_tz), INTERVAL 1 DAY) AS snapshot_start,
DATE_SUB(CURRENT_DATE(reporting_tz), INTERVAL 1 DAY) AS snapshot_end
),
snapshots AS (
SELECT snapshot_date
FROM params, UNNEST(GENERATE_DATE_ARRAY(snapshot_start, snapshot_end)) AS snapshot_date
),
-- Catalog master: current level-1 category per item_id (renames apply retroactively); gift-card and service SKUs flagged for exclusion.
catalog AS (
SELECT
TRIM(item_id) AS item_id,
NULLIF(INITCAP(TRIM(item_category)), '') AS product_area,
COALESCE(LOWER(TRIM(product_type)) IN ('gift_card', 'service', 'warranty', 'shipping_protection'), FALSE) AS is_excluded_sku
FROM `your-project.reference.product_catalog`
WHERE item_id IS NOT NULL
QUALIFY ROW_NUMBER() OVER (PARTITION BY TRIM(item_id) ORDER BY updated_at DESC) = 1
),
-- Staff, internal and known bot identities (user_id or user_pseudo_id).
excluded_users AS (
SELECT DISTINCT user_key
FROM `your-project.reference.excluded_users`
),
test_orders AS (
SELECT DISTINCT transaction_id
FROM `your-project.reference.test_orders`
),
raw_events AS (
SELECT
PARSE_DATE('%Y%m%d', e.event_date) AS event_date,
e.event_timestamp,
e.event_name,
e.user_pseudo_id,
NULLIF(TRIM(e.user_id), '') AS user_id,
e.privacy_info.ads_storage AS ads_storage,
e.ecommerce.transaction_id,
e.ecommerce.purchase_revenue,
e.items,
(SELECT MAX(IF(ep.key = 'ga_session_id', ep.value.int_value, NULL)) FROM UNNEST(e.event_params) AS ep) AS ga_session_id,
(SELECT MAX(IF(ep.key = 'traffic_type', ep.value.string_value, NULL)) FROM UNNEST(e.event_params) AS ep) AS traffic_type,
(SELECT MAX(IF(ep.key = 'debug_mode', COALESCE(ep.value.int_value, SAFE_CAST(ep.value.string_value AS INT64)), NULL)) FROM UNNEST(e.event_params) AS ep) AS debug_mode,
(SELECT MAX(IF(ep.key = 'value', COALESCE(ep.value.double_value, ep.value.float_value, CAST(ep.value.int_value AS FLOAT64)), NULL)) FROM UNNEST(e.event_params) AS ep) AS event_value
FROM `your-project.analytics_123456789.events_*` AS e
WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(reporting_tz), INTERVAL 30 DAY))
AND FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(reporting_tz), INTERVAL 1 DAY))
AND e.event_name IN ('view_item', 'add_to_cart', 'purchase')
AND e.privacy_info.analytics_storage = 'Yes'
AND e.user_pseudo_id IS NOT NULL
),
-- Internal / developer traffic, test orders and zero-value replacement orders removed; refunded purchases are kept.
clean_events AS (
SELECT r.*
FROM raw_events AS r
LEFT JOIN test_orders AS t
ON r.event_name = 'purchase' AND t.transaction_id = r.transaction_id
WHERE t.transaction_id IS NULL
AND COALESCE(LOWER(r.traffic_type), '') NOT IN ('internal', 'developer')
AND COALESCE(r.debug_mode, 0) = 0
AND NOT (r.event_name = 'purchase' AND COALESCE(r.purchase_revenue, r.event_value, -1) = 0)
),
item_rows AS (
SELECT
c.event_date,
c.event_timestamp,
c.event_name,
c.user_pseudo_id,
c.user_id,
c.ads_storage,
CONCAT(c.user_pseudo_id, '.', COALESCE(CAST(c.ga_session_id AS STRING), CONCAT('nosession-', CAST(c.event_timestamp AS STRING)))) AS session_key,
COALESCE(NULLIF(TRIM(it.item_id), ''), '(not set)') AS item_id,
NULLIF(INITCAP(TRIM(it.item_category)), '') AS datalayer_category
FROM clean_events AS c, UNNEST(c.items) AS it
),
-- Device stitching: each user_pseudo_id inherits the most recent user_id it carried inside the snapshot window.
stitch_map AS (
SELECT
s.snapshot_date,
e.user_pseudo_id,
ARRAY_AGG(TRIM(e.user_id) ORDER BY e.event_timestamp DESC LIMIT 1)[OFFSET(0)] AS stitched_user_id
FROM `your-project.analytics_123456789.events_*` AS e
JOIN snapshots AS s
ON PARSE_DATE('%Y%m%d', e.event_date) BETWEEN DATE_SUB(s.snapshot_date, INTERVAL 29 DAY) AND s.snapshot_date
WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(reporting_tz), INTERVAL 30 DAY))
AND FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(reporting_tz), INTERVAL 1 DAY))
AND e.user_id IS NOT NULL
AND TRIM(e.user_id) <> ''
AND e.privacy_info.analytics_storage = 'Yes'
GROUP BY s.snapshot_date, e.user_pseudo_id
),
windowed AS (
SELECT
s.snapshot_date,
i.*,
COALESCE(i.user_id, m.stitched_user_id, i.user_pseudo_id) AS user_key,
IF(COALESCE(i.user_id, m.stitched_user_id) IS NULL, 'user_pseudo_id', 'user_id') AS identity_type
FROM item_rows AS i
JOIN snapshots AS s
ON i.event_date BETWEEN DATE_SUB(s.snapshot_date, INTERVAL 29 DAY) AND s.snapshot_date
LEFT JOIN stitch_map AS m
ON m.snapshot_date = s.snapshot_date AND m.user_pseudo_id = i.user_pseudo_id
),
filtered AS (
SELECT w.*
FROM windowed AS w
LEFT JOIN excluded_users AS x1 ON x1.user_key = w.user_key
LEFT JOIN excluded_users AS x2 ON x2.user_key = w.user_pseudo_id
WHERE x1.user_key IS NULL
AND x2.user_key IS NULL
),
-- One interaction per item: view_item once per item_id per session; add_to_cart / purchase once per item_id per event.
interactions AS (
SELECT
f.snapshot_date,
f.user_key,
f.identity_type,
f.event_name,
f.item_id,
MAX(f.event_timestamp) AS last_ts,
ANY_VALUE(f.datalayer_category) AS datalayer_category
FROM filtered AS f
GROUP BY
f.snapshot_date, f.user_key, f.identity_type, f.event_name, f.item_id,
IF(f.event_name = 'view_item', f.session_key, CONCAT(f.session_key, '.', CAST(f.event_timestamp AS STRING)))
),
categorised AS (
SELECT
i.*,
COALESCE(c.product_area, '(not set)') AS product_area
FROM interactions AS i
LEFT JOIN catalog AS c
ON c.item_id = i.item_id
WHERE NOT COALESCE(c.is_excluded_sku, FALSE)
),
category_counts AS (
SELECT
snapshot_date,
user_key,
product_area,
COUNT(*) AS category_interactions,
COUNTIF(event_name = 'purchase') > 0 AS has_purchase,
COUNTIF(event_name = 'add_to_cart') > 0 AS has_add_to_cart,
MAX(last_ts) AS last_ts
FROM categorised
WHERE product_area <> '(not set)'
GROUP BY snapshot_date, user_key, product_area
),
-- Ranking: interactions, then purchase, then add_to_cart, then most recent, then alphabetical; (not set) never ranks.
top_category AS (
SELECT *
FROM category_counts
WHERE TRUE
QUALIFY ROW_NUMBER() OVER (
PARTITION BY snapshot_date, user_key
ORDER BY category_interactions DESC, has_purchase DESC, has_add_to_cart DESC, last_ts DESC, product_area ASC
) = 1
),
user_totals AS (
SELECT
snapshot_date,
user_key,
MAX(identity_type) AS identity_type,
COUNT(*) AS qualifying_interactions,
COUNTIF(product_area <> '(not set)') AS mapped_interactions,
COUNTIF(product_area = '(not set)') AS unmapped_interactions,
COUNTIF(event_name = 'view_item') AS view_item_interactions,
COUNTIF(event_name = 'add_to_cart') AS add_to_cart_interactions,
COUNTIF(event_name = 'purchase') AS purchase_interactions,
COUNT(DISTINCT NULLIF(product_area, '(not set)')) AS distinct_product_areas,
COUNTIF(product_area <> '(not set)' AND datalayer_category IS NOT NULL AND datalayer_category <> product_area) AS datalayer_category_mismatches,
MAX(last_ts) AS last_ts
FROM categorised
GROUP BY snapshot_date, user_key
),
ads_consent AS (
SELECT
snapshot_date,
user_key,
ARRAY_AGG(ads_storage IGNORE NULLS ORDER BY event_timestamp DESC LIMIT 1)[SAFE_OFFSET(0)] AS latest_ads_storage
FROM filtered
GROUP BY snapshot_date, user_key
)
SELECT
t.snapshot_date,
t.user_key,
t.identity_type,
IF(t.mapped_interactions >= 2 AND tc.product_area IS NOT NULL, tc.product_area, '(not set)') AS product_area_interest,
t.qualifying_interactions,
IF(t.mapped_interactions >= 2 AND tc.product_area IS NOT NULL, tc.category_interactions, 0) AS top_product_area_interactions,
t.mapped_interactions,
t.unmapped_interactions,
t.view_item_interactions,
t.add_to_cart_interactions,
t.purchase_interactions,
t.distinct_product_areas,
t.datalayer_category_mismatches,
TIMESTAMP_MICROS(t.last_ts) AS last_interaction_at,
COALESCE(a.latest_ads_storage = 'Yes', FALSE) AS ad_activation_eligible
FROM user_totals AS t
LEFT JOIN top_category AS tc
ON tc.snapshot_date = t.snapshot_date AND tc.user_key = t.user_key
LEFT JOIN ads_consent AS a
ON a.snapshot_date = t.snapshot_date AND a.user_key = t.user_keyAdobe Experience Platform (AEP Query Service)
-- ==========================================
-- 2. ADOBE AEP QUERY SERVICE SQL
-- ==========================================
-- Product Area Interest (Category Affinity): one row per user per snapshot date.
-- Window: snapshot_date - 29 days .. snapshot_date (30 days, reporting time zone); the daily run uses snapshot_date = yesterday.
-- Sessions are rebuilt with SESS_TIMEOUT (30-minute inactivity) over every web hit of the ECID.
-- Ad consent: ad_activation_eligible is set from the latest consents.share.val (ad-platform data sharing) = 'y'.
-- Consent data absent: datasets without an XDM consents object return no rows under the strict consents.collect.val = 'y' filter.
WITH tz AS (
-- reporting_tz must equal the report-suite / property reporting time zone (calculation note 3).
SELECT 'America/New_York' AS reporting_tz
),
params AS (
SELECT
reporting_tz,
date_sub(to_date(from_utc_timestamp(current_timestamp(), reporting_tz)), 1) AS snapshot_start,
date_sub(to_date(from_utc_timestamp(current_timestamp(), reporting_tz)), 1) AS snapshot_end
FROM tz
),
snapshots AS (
SELECT explode(sequence(snapshot_start, snapshot_end, interval 1 day)) AS snapshot_date
FROM params
),
-- Catalog master: current level-1 category per SKU (renames apply retroactively); gift-card and service SKUs flagged for exclusion.
catalog AS (
SELECT item_id, product_area, is_excluded_sku
FROM (
SELECT
trim(sku) AS item_id,
NULLIF(initcap(trim(category_level1)), '') AS product_area,
COALESCE(lower(trim(product_type)) IN ('gift_card', 'service', 'warranty', 'shipping_protection'), FALSE) AS is_excluded_sku,
ROW_NUMBER() OVER (PARTITION BY trim(sku) ORDER BY updated_at DESC) AS rn
FROM product_catalog_reference
WHERE sku IS NOT NULL
) AS c
WHERE rn = 1
),
-- Staff, internal and known bot identities (CRMID or ECID).
excluded_identities AS (
SELECT DISTINCT identity_id
FROM excluded_identities_reference
),
test_orders AS (
SELECT DISTINCT purchase_id
FROM test_orders_reference
),
web_events AS (
SELECT
e._id AS event_id,
e.timestamp AS event_ts,
to_date(from_utc_timestamp(e.timestamp, p.reporting_tz)) AS event_date,
e.eventType AS event_type,
e.identityMap['ECID'][0].id AS ecid,
NULLIF(trim(e.identityMap['CRMID'][0].id), '') AS crm_id,
e.consents.collect.val AS collect_consent,
e.consents.share.val AS share_consent,
e.commerce.order.purchaseID AS purchase_id,
e.commerce.order.priceTotal AS order_total,
e.environment.browserDetails.userAgent AS user_agent,
e.productListItems AS product_list_items,
SESS_TIMEOUT(e.timestamp, 60 * 30) OVER (
PARTITION BY e.identityMap['ECID'][0].id
ORDER BY e.timestamp
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS session
FROM aep_web_experience_events AS e
CROSS JOIN params AS p
WHERE e.timestamp >= to_utc_timestamp(CAST(date_sub(p.snapshot_start, 30) AS TIMESTAMP), p.reporting_tz)
AND e.timestamp < to_utc_timestamp(CAST(date_add(p.snapshot_end, 1) AS TIMESTAMP), p.reporting_tz)
AND e.identityMap['ECID'][0].id IS NOT NULL
),
-- Analytics consent only; bots, test orders and zero-value replacement orders removed; refunded purchases are kept.
clean_events AS (
SELECT w.*
FROM web_events AS w
LEFT JOIN test_orders AS t
ON w.event_type = 'commerce.purchases' AND t.purchase_id = w.purchase_id
WHERE w.event_type IN ('commerce.productViews', 'commerce.productListAdds', 'commerce.purchases')
AND w.collect_consent = 'y'
AND t.purchase_id IS NULL
AND NOT (w.event_type = 'commerce.purchases' AND COALESCE(w.order_total, -1) = 0)
AND NOT COALESCE(lower(w.user_agent) RLIKE '(bot|crawler|spider|headlesschrome|lighthouse)', FALSE)
),
item_rows AS (
SELECT
c.event_id,
c.event_ts,
c.event_date,
c.event_type,
c.ecid,
c.crm_id,
c.share_consent,
concat(c.ecid, '.', CAST(c.session.num AS STRING)) AS session_key,
COALESCE(NULLIF(trim(pli.SKU), ''), '(not set)') AS item_id,
NULLIF(initcap(trim(COALESCE(pli.productCategories[0].categoryName, split(pli.productCategories[0].hierarchy, '/')[0]))), '') AS datalayer_category
FROM clean_events AS c
LATERAL VIEW explode(c.product_list_items) pli_view AS pli
),
-- Device stitching: each ECID inherits the most recent CRMID it carried inside the snapshot window.
stitch_map AS (
SELECT snapshot_date, ecid, crm_id AS stitched_crm_id
FROM (
SELECT
s.snapshot_date,
w.ecid,
w.crm_id,
ROW_NUMBER() OVER (PARTITION BY s.snapshot_date, w.ecid ORDER BY w.event_ts DESC) AS rn
FROM web_events AS w
JOIN snapshots AS s
ON w.event_date BETWEEN date_sub(s.snapshot_date, 29) AND s.snapshot_date
WHERE w.crm_id IS NOT NULL
AND w.collect_consent = 'y'
) AS m
WHERE rn = 1
),
windowed AS (
SELECT
s.snapshot_date,
i.*,
COALESCE(i.crm_id, m.stitched_crm_id, i.ecid) AS user_key,
CASE WHEN COALESCE(i.crm_id, m.stitched_crm_id) IS NULL THEN 'ECID' ELSE 'CRMID' END AS identity_type
FROM item_rows AS i
JOIN snapshots AS s
ON i.event_date BETWEEN date_sub(s.snapshot_date, 29) AND s.snapshot_date
LEFT JOIN stitch_map AS m
ON m.snapshot_date = s.snapshot_date AND m.ecid = i.ecid
),
filtered AS (
SELECT w.*
FROM windowed AS w
LEFT JOIN excluded_identities AS x1 ON x1.identity_id = w.user_key
LEFT JOIN excluded_identities AS x2 ON x2.identity_id = w.ecid
WHERE x1.identity_id IS NULL
AND x2.identity_id IS NULL
),
-- One interaction per item: product view once per SKU per session; cart add / purchase once per SKU per event.
interactions AS (
SELECT
snapshot_date,
user_key,
identity_type,
event_type,
item_id,
MAX(event_ts) AS last_ts,
MAX(datalayer_category) AS datalayer_category
FROM filtered
GROUP BY
snapshot_date, user_key, identity_type, event_type, item_id,
CASE WHEN event_type = 'commerce.productViews' THEN session_key ELSE event_id END
),
categorised AS (
SELECT
i.*,
COALESCE(c.product_area, '(not set)') AS product_area
FROM interactions AS i
LEFT JOIN catalog AS c
ON c.item_id = i.item_id
WHERE NOT COALESCE(c.is_excluded_sku, FALSE)
),
category_counts AS (
SELECT
snapshot_date,
user_key,
product_area,
COUNT(*) AS category_interactions,
MAX(CASE WHEN event_type = 'commerce.purchases' THEN 1 ELSE 0 END) AS has_purchase,
MAX(CASE WHEN event_type = 'commerce.productListAdds' THEN 1 ELSE 0 END) AS has_add_to_cart,
MAX(last_ts) AS last_ts
FROM categorised
WHERE product_area <> '(not set)'
GROUP BY snapshot_date, user_key, product_area
),
-- Ranking: interactions, then purchase, then cart add, then most recent, then alphabetical; (not set) never ranks.
top_category AS (
SELECT snapshot_date, user_key, product_area, category_interactions
FROM (
SELECT
cc.*,
ROW_NUMBER() OVER (
PARTITION BY snapshot_date, user_key
ORDER BY category_interactions DESC, has_purchase DESC, has_add_to_cart DESC, last_ts DESC, product_area ASC
) AS rn
FROM category_counts AS cc
) AS r
WHERE rn = 1
),
user_totals AS (
SELECT
snapshot_date,
user_key,
MAX(identity_type) AS identity_type,
COUNT(*) AS qualifying_interactions,
SUM(CASE WHEN product_area <> '(not set)' THEN 1 ELSE 0 END) AS mapped_interactions,
SUM(CASE WHEN product_area = '(not set)' THEN 1 ELSE 0 END) AS unmapped_interactions,
SUM(CASE WHEN event_type = 'commerce.productViews' THEN 1 ELSE 0 END) AS view_item_interactions,
SUM(CASE WHEN event_type = 'commerce.productListAdds' THEN 1 ELSE 0 END) AS add_to_cart_interactions,
SUM(CASE WHEN event_type = 'commerce.purchases' THEN 1 ELSE 0 END) AS purchase_interactions,
COUNT(DISTINCT CASE WHEN product_area <> '(not set)' THEN product_area END) AS distinct_product_areas,
SUM(CASE WHEN product_area <> '(not set)' AND datalayer_category IS NOT NULL AND datalayer_category <> product_area THEN 1 ELSE 0 END) AS datalayer_category_mismatches,
MAX(last_ts) AS last_ts
FROM categorised
GROUP BY snapshot_date, user_key
),
share_consent_latest AS (
SELECT snapshot_date, user_key, share_consent AS latest_share_consent
FROM (
SELECT
snapshot_date,
user_key,
share_consent,
ROW_NUMBER() OVER (PARTITION BY snapshot_date, user_key ORDER BY event_ts DESC) AS rn
FROM filtered
WHERE share_consent IS NOT NULL
) AS sc
WHERE rn = 1
)
SELECT
t.snapshot_date,
t.user_key,
t.identity_type,
CASE WHEN t.mapped_interactions >= 2 AND tc.product_area IS NOT NULL THEN tc.product_area ELSE '(not set)' END AS product_area_interest,
t.qualifying_interactions,
CASE WHEN t.mapped_interactions >= 2 AND tc.product_area IS NOT NULL THEN tc.category_interactions ELSE 0 END AS top_product_area_interactions,
t.mapped_interactions,
t.unmapped_interactions,
t.view_item_interactions,
t.add_to_cart_interactions,
t.purchase_interactions,
t.distinct_product_areas,
t.datalayer_category_mismatches,
t.last_ts AS last_interaction_at,
COALESCE(sc.latest_share_consent = 'y', FALSE) AS ad_activation_eligible
FROM user_totals AS t
LEFT JOIN top_category AS tc
ON tc.snapshot_date = t.snapshot_date AND tc.user_key = t.user_key
LEFT JOIN share_consent_latest AS sc
ON sc.snapshot_date = t.snapshot_date AND sc.user_key = t.user_keySource Data
Digital Analytics (GA4) + Product Catalog
Dependencies
Prerequisite: List the dependencies required for this Dimension
Report Attributes
Default Channel Grouping Device Category New vs Returning User Customer Lifecycle Stage Login Status Country/Region
Segment Eligibility
true
Governance
Derived Dimensions
Governance
GitHub
catalog/dimensions/product-area-interest.yml