Chapter 16: Priority Scoring Engine
Scoring Architecture
The priority scoring engine is the core business logic of AdPriority. It takes a product variant as input and produces a single integer from 0 to 5, along with five custom label strings for the Google Merchant Center supplemental feed. The engine evaluates a layered stack of rules in a fixed order, where higher-priority layers can override lower ones.
SCORING PIPELINE
================
Input: Product + Variant + Tenant Config
|
v
+-----------------------------+
| Layer 1: CATEGORY RULES | Base priority from product type mapping
| "outerwear-heavy" --> 3 | (20 category groups from 90 product types)
+-----------------------------+
|
v
+-----------------------------+
| Layer 2: SEASON MODIFIER | Override base with seasonal value
| Winter + outerwear --> 5 | (from seasonal_calendars.category_overrides)
+-----------------------------+
|
v
+-----------------------------+
| Layer 3: INVENTORY MOD | Adjust based on stock levels
| 0 qty --> force 0 | (dead stock, low stock, overstock)
| <5 qty --> -1 |
+-----------------------------+
|
v
+-----------------------------+
| Layer 4: TAG MODIFIERS | Adjust based on product tags
| "DEAD50" --> force 0 | (overrides and adjustments)
| "NAME BRAND" --> +1 |
| "Sale" --> -1 |
+-----------------------------+
|
v
+-----------------------------+
| Layer 5: NEW ARRIVAL | Boost recent products
| <30 days --> min score 5 | (configurable threshold)
+-----------------------------+
|
v
+-----------------------------+
| Layer 6: MANUAL OVERRIDE | Merchant locked score
| If override=true, skip | (highest priority, never recalculated)
| all layers above |
+-----------------------------+
|
v
+-----------------------------+
| CLAMP: 0-5 | Floor at 0, cap at 5
+-----------------------------+
|
v
+-----------------------------+
| GENERATE CUSTOM LABELS | 5 labels for GMC feed
| label_0: priority-{score} |
| label_1: {season} |
| label_2: {category_group} |
| label_3: {inventory_status}|
| label_4: {brand_tier} |
+-----------------------------+
|
v
Output: PriorityResult {
priority: 5,
customLabels: { label0..label4 },
source: "seasonal",
appliedRules: [...]
}
Category Groups
Nexus Clothing has 90 unique product types following a hierarchical naming convention (Gender-Department-SubCategory-Detail). Managing 90 individual rules would be unworkable, so AdPriority consolidates them into 20 category groups. Each group maps one or more product type strings to a single scoring profile.
Category Group Mapping
| # | Category Group | Product Types Included | Product Count | Default Priority |
|---|---|---|---|---|
| 1 | t-shirts | Men-Tops-T-Shirts, Boys-Tops-T-Shirts, Men-Tops-Crop-Top, Men-Tops-Tank Tops, T-Shirt | 1,363 | 3 |
| 2 | long-sleeve-tops | Men-Tops-T-Shirts-Long Sleeve | 54 | 3 |
| 3 | jeans-pants | Men-Bottoms-Pants-Jeans, Men-Bottoms-Stacked Jeans, Boys-Bottoms-Jeans, Men-Bottoms-Pants-Track Pants, Men-Bottoms-Pants-Cargo, Men-Bottoms-Stacked Track Pants | 911 | 4 |
| 4 | sweatpants | Men-Bottoms-Pants-Sweatpants, Men-Bottoms-Stacked Sweatpants | 94 | 3 |
| 5 | shorts | Men-Bottoms-Shorts, Men-Bottoms-Shorts-Denim, Men-Bottoms-Shorts-Cargo, Men-Bottoms-Shorts-Mesh, Men-Bottoms-Shorts-Basic-Fleece, Men-Bottoms-Shorts-Fashion-Shorts, Boys-Bottoms-Shorts, Women-Shorts, Shorts-Swimming-Trunks | 315 | 3 |
| 6 | swim-shorts | Men-Bottoms-Shorts-Swim-Shorts | 40 | 2 |
| 7 | hoodies-sweatshirts | Men-Tops-Hoodies & Sweatshirts, Boys-Tops-Hoodies, Sweatshirts & Hoodies | 264 | 3 |
| 8 | outerwear-heavy | Men-Tops-Outerwear-Jackets-Puffer Jackets, Men-Tops-Outerwear-Jackets-Coats-Shearling | 72 | 3 |
| 9 | outerwear-medium | Men-Tops-Outerwear-Jackets-Denim Jackets, Men-Tops-Outerwear-Jackets-Varsity Jackets, Men-Tops-Outerwear-Jackets-Fleece, Men-Tops-Outerwear-Jackets-Sports Jackets, Men-Tops-Outerwear-Vests, Boys-Tops-Jackets-Coats, Boys-Tops-Denim-Jackets | 229 | 3 |
| 10 | outerwear-light | Men-Tops-Outerwear-Jackets-Track Jackets, Men-Tops-Outerwear-Jackets-Windbreaker | 39 | 3 |
| 11 | headwear-caps | Headwear-Baseball-Fitted, Headwear-Baseball-Dad Hat, Headwear-Baseball-Snapback, Headwear-Baseball-Low Profile Fitted | 777 | 3 |
| 12 | headwear-cold | Headwear-Knit Beanies, Balaclavas | 171 | 2 |
| 13 | headwear-summer | Headwear-Bucket Hat | 51 | 2 |
| 14 | joggers | Men-Bottoms-Joggers, Boys-Bottoms-Joggers | 86 | 3 |
| 15 | footwear-sandals | Men-Footwear-Sandals & Slides | 58 | 2 |
| 16 | footwear-shoes | Men-Footwear-Shoes, Women-Footwear-Shoes | 48 | 3 |
| 17 | underwear-socks | Men-Underwear, Women-Underwear, Socks, Boys-Bottoms-Underwear | 523 | 2 |
| 18 | accessories | Accessories, Accessories-Bags, Accessories-Bags-Duffle Bags, Accessories-Bags-Smell Proof Bags, Accessories-Bags-Crossbody bags, Accessories-Jewelry, Accessories-Wallet Chains, Belts | 350 | 2 |
| 19 | women-apparel | Women-Tops, Women-Leggings, Women-Dresses-Mini Dresses, Women-Dresses-Maxi Dresses, Women-Dresses-Midi Dresses, Women-Jumpsuits & Rompers-Rompers, Women-Jumpsuits & Rompers-Jumpsuits, Women-Tops-Outerwear-Jackets, Women-Bottoms-Skinny-Jeans, Women-Bottoms-Pants, Women-Sets-Pant Sets | 80 | 2 |
| 20 | exclude | Bath & Body, Household Cleaning Supplies-Cloth, Household Cleaning Supplies-Steel, Household Cleaning Supplies-Sponge, Gift Cards, Insurance, UpCart - Shipping Protection, Test Category, Sample | ~50 | 0 |
Category Group Resolver
The resolver function matches a Shopify product type string to one of the 20 category groups. If no match is found, the product falls into a default group with priority 2.
// src/services/priority/categoryResolver.ts
/**
* Map of category group names to the Shopify product types they contain.
* Loaded from the database (category_rules table) at scoring time,
* but shown here as a static reference for clarity.
*/
const CATEGORY_GROUP_MAP: Record<string, string[]> = {
't-shirts': [
'Men-Tops-T-Shirts', 'Boys-Tops-T-Shirts',
'Men-Tops-Crop-Top', 'Men-Tops-Tank Tops', 'T-Shirt',
],
'long-sleeve-tops': [
'Men-Tops-T-Shirts-Long Sleeve',
],
'jeans-pants': [
'Men-Bottoms-Pants-Jeans', 'Men-Bottoms-Stacked Jeans',
'Boys-Bottoms-Jeans', 'Men-Bottoms-Pants-Track Pants',
'Men-Bottoms-Pants-Cargo', 'Men-Bottoms-Stacked Track Pants',
],
'sweatpants': [
'Men-Bottoms-Pants-Sweatpants', 'Men-Bottoms-Stacked Sweatpants',
],
'shorts': [
'Men-Bottoms-Shorts', 'Men-Bottoms-Shorts-Denim',
'Men-Bottoms-Shorts-Cargo', 'Men-Bottoms-Shorts-Mesh',
'Men-Bottoms-Shorts-Basic-Fleece', 'Men-Bottoms-Shorts-Fashion-Shorts',
'Boys-Bottoms-Shorts', 'Women-Shorts', 'Shorts-Swimming-Trunks',
],
'swim-shorts': [
'Men-Bottoms-Shorts-Swim-Shorts',
],
'hoodies-sweatshirts': [
'Men-Tops-Hoodies & Sweatshirts', 'Boys-Tops-Hoodies',
'Sweatshirts & Hoodies',
],
'outerwear-heavy': [
'Men-Tops-Outerwear-Jackets-Puffer Jackets',
'Men-Tops-Outerwear-Jackets-Coats-Shearling',
],
'outerwear-medium': [
'Men-Tops-Outerwear-Jackets-Denim Jackets',
'Men-Tops-Outerwear-Jackets-Varsity Jackets',
'Men-Tops-Outerwear-Jackets-Fleece',
'Men-Tops-Outerwear-Jackets-Sports Jackets',
'Men-Tops-Outerwear-Vests',
'Boys-Tops-Jackets-Coats',
'Boys-Tops-Denim-Jackets',
],
'outerwear-light': [
'Men-Tops-Outerwear-Jackets-Track Jackets',
'Men-Tops-Outerwear-Jackets-Windbreaker',
],
'headwear-caps': [
'Headwear-Baseball-Fitted', 'Headwear-Baseball-Dad Hat',
'Headwear-Baseball-Snapback', 'Headwear-Baseball-Low Profile Fitted',
],
'headwear-cold': [
'Headwear-Knit Beanies', 'Balaclavas',
],
'headwear-summer': [
'Headwear-Bucket Hat',
],
'joggers': [
'Men-Bottoms-Joggers', 'Boys-Bottoms-Joggers',
],
'footwear-sandals': [
'Men-Footwear-Sandals & Slides',
],
'footwear-shoes': [
'Men-Footwear-Shoes', 'Women-Footwear-Shoes',
],
'underwear-socks': [
'Men-Underwear', 'Women-Underwear', 'Socks', 'Boys-Bottoms-Underwear',
],
'accessories': [
'Accessories', 'Accessories-Bags', 'Accessories-Bags-Duffle Bags',
'Accessories-Bags-Smell Proof Bags', 'Accessories-Bags-Crossbody bags',
'Accessories-Jewelry', 'Accessories-Wallet Chains', 'Belts',
],
'women-apparel': [
'Women-Tops', 'Women-Leggings', 'Women-Dresses-Mini Dresses',
'Women-Dresses-Maxi Dresses', 'Women-Dresses-Midi Dresses',
'Women-Jumpsuits & Rompers-Rompers', 'Women-Jumpsuits & Rompers-Jumpsuits',
'Women-Tops-Outerwear-Jackets', 'Women-Bottoms-Skinny-Jeans',
'Women-Bottoms-Pants', 'Women-Sets-Pant Sets',
],
'exclude': [
'Bath & Body', 'Household Cleaning Supplies-Cloth',
'Household Cleaning Supplies-Steel', 'Household Cleaning Supplies-Sponge',
'Gift Cards', 'Insurance', 'UpCart - Shipping Protection',
'Test Category', 'Sample',
],
};
/**
* Resolve a Shopify product type to a category group name.
* Returns 'unknown' if no group matches.
*/
export function resolveCategoryGroup(productType: string | null): string {
if (!productType) return 'unknown';
for (const [group, types] of Object.entries(CATEGORY_GROUP_MAP)) {
if (types.includes(productType)) {
return group;
}
}
return 'unknown';
}
Season Modifiers
Each category group has a priority value per season, defined in the seasonal_calendars.category_overrides JSON column. When a season is active, the seasonal value replaces the base priority from the category rule.
Category-Season Priority Matrix
CATEGORY-SEASON PRIORITY MATRIX
================================
Category Group | Winter | Spring | Summer | Fall | Default
---------------------+--------+--------+--------+------+--------
outerwear-heavy | 5 | 1 | 0 | 4 | 3
outerwear-medium | 4 | 3 | 0 | 4 | 3
outerwear-light | 2 | 4 | 1 | 3 | 3
hoodies-sweatshirts | 5 | 3 | 1 | 5 | 3
headwear-cold | 5 | 1 | 0 | 3 | 2
jeans-pants | 4 | 4 | 3 | 5 | 4
sweatpants | 4 | 3 | 1 | 4 | 3
joggers | 4 | 3 | 2 | 4 | 3
long-sleeve-tops | 4 | 3 | 1 | 4 | 3
headwear-caps | 3 | 3 | 3 | 3 | 3
footwear-shoes | 3 | 3 | 3 | 3 | 3
t-shirts | 2 | 4 | 5 | 3 | 3
shorts | 0 | 3 | 5 | 1 | 3
swim-shorts | 0 | 2 | 5 | 0 | 2
footwear-sandals | 0 | 2 | 5 | 0 | 2
headwear-summer | 0 | 3 | 4 | 2 | 2
underwear-socks | 2 | 2 | 2 | 2 | 2
accessories | 2 | 2 | 2 | 2 | 2
women-apparel | 2 | 3 | 3 | 2 | 2
exclude | 0 | 0 | 0 | 0 | 0
The matrix above shows the seasonal priority for each category group. During winter, heavy outerwear and hoodies get the maximum score (5) while shorts, swim shorts, and sandals are excluded (0). In summer, the pattern reverses completely.
Inventory Modifiers
Inventory levels directly affect whether a variant should be advertised. There is no point paying for clicks on a variant that is out of stock.
| Condition | Threshold | Effect | Reason |
|---|---|---|---|
| Dead stock | inventory_quantity = 0 | Override to 0 | Cannot fulfill orders |
| Low inventory | inventory_quantity < 5 | Adjustment: -1 | Reduce spend, risk of stockout |
| In stock | inventory_quantity >= 5 | No change | Normal operation |
| Overstock | inventory_quantity > 50 | Adjustment: -1 | Already have plenty, reduce spend to preserve margin |
The overstock modifier is intentionally conservative. A store with 50+ units of a variant does not need aggressive advertising to move that specific item unless it is a seasonal push. If the seasonal layer already assigns a high priority, the -1 from overstock is appropriate to moderate spend slightly.
Tag Modifiers
Nexus Clothing uses a rich tagging system with 2,522 unique tags. AdPriority evaluates specific tags in priority order. Tag modifiers come in two flavors: overrides (force a specific score regardless of other layers) and adjustments (add or subtract from the current score).
Tag Modifier Rules
| Tag | Type | Value | Reason | Products Affected |
|---|---|---|---|---|
archived | Override | 0 | Archived products must not be advertised | 3,130 |
DEAD50 | Override | 0 | Dead stock at 50% off, exclude from ads | 615 |
warning_inv_1 | Adjustment | -1 | Low inventory warning from Shopify | 3,619 |
warning_inv | Adjustment | -1 | General inventory warning | 1,372 |
in-stock | Adjustment | +1 | Available and ready to ship | 930 |
NAME BRAND | Adjustment | +1 | Premium brand, higher expected ROAS | 2,328 |
Sale | Adjustment | -1 | Already discounted, lower margin | 1,471 |
Tag Evaluation Order
Tags are evaluated in a specific order because overrides short-circuit the pipeline:
TAG EVALUATION ORDER
====================
1. Check for override tags (processed first, any match stops evaluation)
- "archived" --> return 0 immediately
- "DEAD50" --> return 0 immediately
2. Check for adjustment tags (all matching tags apply cumulatively)
- "warning_inv_1" --> score -= 1
- "warning_inv" --> score -= 1
- "in-stock" --> score += 1
- "NAME BRAND" --> score += 1
- "Sale" --> score -= 1
3. Clamp result to 0-5 range
A product can have multiple adjustment tags, and they stack. For example, a product tagged with both NAME BRAND (+1) and Sale (-1) sees no net adjustment. A product tagged with NAME BRAND (+1) and in-stock (+1) gains +2.
New Arrival Boost
Products created within a configurable number of days (default: 30) receive an automatic priority boost. The new arrival boost sets a floor rather than adding to the score: if the product already has a score of 5, the boost has no effect. If the product has a score of 2, it is raised to the configured minimum (default: 5).
| Setting | Default | Description |
|---|---|---|
newArrivalDays | 30 | Days since product creation to qualify |
newArrivalPriority | 5 | Minimum priority for qualifying products |
The new arrival boost evaluates after tag modifiers but before manual overrides. This means a new arrival tagged with DEAD50 will still be excluded (tag override takes precedence because it returns immediately), but a new arrival with no override tags will be boosted to at least priority 5.
Score Clamping
After all layers have been applied, the final score is clamped to the 0-5 range. This is a simple floor/ceiling operation:
- If the accumulated score is less than 0, it becomes 0.
- If the accumulated score is greater than 5, it becomes 5.
This guarantees that the output is always a valid priority value regardless of how many adjustments stacked.
Custom Label Generation
After the priority score is calculated, five custom labels are generated for the Google Merchant Center supplemental feed. Each label encodes a different dimension of the product’s classification.
| Label | Format | Source | Example Values |
|---|---|---|---|
custom_label_0 | priority-{score} | Calculated priority | priority-0 through priority-5 |
custom_label_1 | {season} | Current active season | winter, spring, summer, fall |
custom_label_2 | {category_group} | Category resolver | outerwear-heavy, t-shirts, jeans-pants |
custom_label_3 | {inventory_status} | Inventory + tags analysis | new-arrival, in-stock, low-inventory, clearance, dead-stock |
custom_label_4 | {brand_tier} | Vendor + tag analysis | name-brand, store-brand, off-brand |
Inventory Status Determination
function getInventoryStatus(
product: { tags: string[]; status: string; createdAt: Date },
variant: { inventoryQuantity: number },
newArrivalDays: number
): string {
// Override statuses (checked first)
if (product.tags.includes('archived') || product.status === 'archived') return 'dead-stock';
if (product.tags.includes('DEAD50')) return 'dead-stock';
if (variant.inventoryQuantity === 0) return 'dead-stock';
// Warning statuses
if (product.tags.includes('warning_inv_1')) return 'low-inventory';
if (product.tags.includes('warning_inv')) return 'low-inventory';
// Positive statuses
const daysSinceCreated = daysBetween(product.createdAt, new Date());
if (daysSinceCreated <= newArrivalDays) return 'new-arrival';
// Clearance
if (product.tags.includes('Sale')) return 'clearance';
// Default
return 'in-stock';
}
Brand Tier Determination
function getBrandTier(product: { vendor: string | null; tags: string[] }): string {
// Check store brand first
if (product.vendor === 'Nexus Clothing') return 'store-brand';
// Check tags
if (product.tags.includes('NAME BRAND')) return 'name-brand';
if (product.tags.includes('OFF BRAND')) return 'off-brand';
// Fallback: known premium vendors
const KNOWN_NAME_BRANDS = [
'New Era', 'Jordan Craig', 'Psycho Bunny', 'LACOSTE',
'Gstar Raw', 'Ed Hardy', 'Kappa', 'RVCA', "Levi's",
];
if (product.vendor && KNOWN_NAME_BRANDS.includes(product.vendor)) {
return 'name-brand';
}
return 'off-brand';
}
Core Scoring Function
The following TypeScript function implements the complete scoring pipeline. It is called once per variant and returns the priority score along with all five custom labels and metadata about which rules were applied.
// src/services/priority/scorer.ts
import { resolveCategoryGroup } from './categoryResolver';
// ---- Types ----
interface ProductInput {
id: string;
productType: string | null;
vendor: string | null;
tags: string[];
status: string;
createdAt: Date;
}
interface VariantInput {
id: string;
shopifyVariantId: bigint;
inventoryQuantity: number;
}
interface ScoringConfig {
currentSeason: 'winter' | 'spring' | 'summer' | 'fall';
seasonOverrides: Record<string, number>; // category_group -> priority
defaultPriority: number;
newArrivalDays: number;
newArrivalPriority: number;
tagModifiers: Record<string, { override?: number; adjustment?: number }>;
inventoryModifiers: {
zeroStockOverride: number;
lowStockThreshold: number;
lowStockAdjustment: number;
overstockThreshold: number;
overstockAdjustment: number;
};
}
interface ExistingScore {
override: boolean;
priority: number;
overrideReason: string | null;
}
interface PriorityResult {
priority: number;
source: string;
categoryGroup: string;
customLabel0: string;
customLabel1: string;
customLabel2: string;
customLabel3: string;
customLabel4: string;
appliedRules: string[];
}
// ---- Main Scoring Function ----
export function calculatePriority(
product: ProductInput,
variant: VariantInput,
config: ScoringConfig,
existingScore?: ExistingScore | null
): PriorityResult {
const appliedRules: string[] = [];
const categoryGroup = resolveCategoryGroup(product.productType);
// ------------------------------------------------------------------
// Layer 0: Check for manual override (highest priority)
// ------------------------------------------------------------------
if (existingScore?.override) {
return buildResult({
priority: existingScore.priority,
source: 'manual',
categoryGroup,
product,
variant,
config,
appliedRules: [`manual-override: ${existingScore.overrideReason || 'no reason'}`],
});
}
// ------------------------------------------------------------------
// Layer 1: Base priority from category rules
// ------------------------------------------------------------------
let priority = config.defaultPriority;
let source = 'default';
// Look up the default (season-agnostic) base priority for this group
// In practice, this comes from category_rules WHERE season IS NULL
// For now, the config.defaultPriority serves as fallback
if (categoryGroup === 'exclude') {
priority = 0;
source = 'category';
appliedRules.push(`category: ${categoryGroup} -> 0 (excluded)`);
// Short-circuit: excluded categories skip all other layers
return buildResult({ priority, source, categoryGroup, product, variant, config, appliedRules });
}
appliedRules.push(`category-default: ${categoryGroup} -> ${priority}`);
// ------------------------------------------------------------------
// Layer 2: Season modifier (overrides base)
// ------------------------------------------------------------------
const seasonalPriority = config.seasonOverrides[categoryGroup];
if (seasonalPriority !== undefined) {
priority = seasonalPriority;
source = 'seasonal';
appliedRules.push(`season-${config.currentSeason}: ${categoryGroup} -> ${priority}`);
}
// ------------------------------------------------------------------
// Layer 3: Inventory modifiers
// ------------------------------------------------------------------
if (variant.inventoryQuantity === 0) {
priority = config.inventoryModifiers.zeroStockOverride;
source = 'inventory';
appliedRules.push('inventory: zero stock -> 0');
// Zero stock is a hard override, return immediately
return buildResult({ priority, source, categoryGroup, product, variant, config, appliedRules });
}
if (variant.inventoryQuantity < config.inventoryModifiers.lowStockThreshold) {
priority += config.inventoryModifiers.lowStockAdjustment;
appliedRules.push(
`inventory: low stock (${variant.inventoryQuantity} < ${config.inventoryModifiers.lowStockThreshold}) -> ${config.inventoryModifiers.lowStockAdjustment}`
);
}
if (variant.inventoryQuantity > config.inventoryModifiers.overstockThreshold) {
priority += config.inventoryModifiers.overstockAdjustment;
appliedRules.push(
`inventory: overstock (${variant.inventoryQuantity} > ${config.inventoryModifiers.overstockThreshold}) -> ${config.inventoryModifiers.overstockAdjustment}`
);
}
// ------------------------------------------------------------------
// Layer 4: Tag modifiers
// ------------------------------------------------------------------
for (const tag of product.tags) {
const modifier = config.tagModifiers[tag];
if (!modifier) continue;
// Override tags short-circuit
if (modifier.override !== undefined) {
priority = modifier.override;
source = 'tag-override';
appliedRules.push(`tag-override: "${tag}" -> ${modifier.override}`);
return buildResult({ priority, source, categoryGroup, product, variant, config, appliedRules });
}
// Adjustment tags accumulate
if (modifier.adjustment !== undefined) {
priority += modifier.adjustment;
appliedRules.push(`tag-adjust: "${tag}" -> ${modifier.adjustment > 0 ? '+' : ''}${modifier.adjustment}`);
}
}
// ------------------------------------------------------------------
// Layer 5: New arrival boost
// ------------------------------------------------------------------
const daysSinceCreated = daysBetween(product.createdAt, new Date());
if (daysSinceCreated <= config.newArrivalDays) {
if (priority < config.newArrivalPriority) {
const previousPriority = priority;
priority = config.newArrivalPriority;
source = 'new-arrival';
appliedRules.push(
`new-arrival: ${daysSinceCreated} days old, boosted from ${previousPriority} to ${priority}`
);
}
}
// ------------------------------------------------------------------
// Clamp to 0-5
// ------------------------------------------------------------------
priority = clamp(priority, 0, 5);
return buildResult({ priority, source, categoryGroup, product, variant, config, appliedRules });
}
// ---- Helper Functions ----
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function daysBetween(date1: Date, date2: Date): number {
const ms = Math.abs(date2.getTime() - date1.getTime());
return Math.floor(ms / (1000 * 60 * 60 * 24));
}
interface BuildResultInput {
priority: number;
source: string;
categoryGroup: string;
product: ProductInput;
variant: VariantInput;
config: ScoringConfig;
appliedRules: string[];
}
function buildResult(input: BuildResultInput): PriorityResult {
const { priority, source, categoryGroup, product, variant, config, appliedRules } = input;
const inventoryStatus = getInventoryStatus(product, variant, config.newArrivalDays);
const brandTier = getBrandTier(product);
return {
priority: clamp(priority, 0, 5),
source,
categoryGroup,
customLabel0: `priority-${clamp(priority, 0, 5)}`,
customLabel1: config.currentSeason,
customLabel2: categoryGroup,
customLabel3: inventoryStatus,
customLabel4: brandTier,
appliedRules,
};
}
function getInventoryStatus(
product: ProductInput,
variant: VariantInput,
newArrivalDays: number
): string {
if (product.tags.includes('archived') || product.status === 'archived') return 'dead-stock';
if (product.tags.includes('DEAD50')) return 'dead-stock';
if (variant.inventoryQuantity === 0) return 'dead-stock';
if (product.tags.includes('warning_inv_1')) return 'low-inventory';
if (product.tags.includes('warning_inv')) return 'low-inventory';
const daysSinceCreated = daysBetween(product.createdAt, new Date());
if (daysSinceCreated <= newArrivalDays) return 'new-arrival';
if (product.tags.includes('Sale')) return 'clearance';
return 'in-stock';
}
function getBrandTier(product: ProductInput): string {
if (product.vendor === 'Nexus Clothing') return 'store-brand';
if (product.tags.includes('NAME BRAND')) return 'name-brand';
if (product.tags.includes('OFF BRAND')) return 'off-brand';
const KNOWN_NAME_BRANDS = [
'New Era', 'Jordan Craig', 'Psycho Bunny', 'LACOSTE',
'Gstar Raw', 'Ed Hardy', 'Kappa', 'RVCA', "Levi's",
];
if (product.vendor && KNOWN_NAME_BRANDS.includes(product.vendor)) return 'name-brand';
return 'off-brand';
}
Recalculation Triggers
The scoring engine is invoked under four circumstances. Each trigger type has different scope and performance characteristics.
RECALCULATION TRIGGERS
======================
1. WEBHOOK (real-time, single product)
Trigger: Shopify products/create or products/update webhook
Scope: One product + all its variants
Latency: < 2 seconds
When: Product type changed, tags changed, new product created
2. SCHEDULED (daily, full catalog)
Trigger: Cron job at 2:00 AM tenant local time
Scope: All non-overridden variants for the tenant
Latency: 2-5 minutes for 20,000 variants
When: Every day (catches inventory changes, date-based rules)
3. MANUAL (on-demand, configurable scope)
Trigger: Merchant clicks "Recalculate" in the UI
Scope: All variants, or filtered by product type
Latency: 2-5 minutes for 20,000 variants
When: After rule changes, after season edits
4. SEASON CHANGE (automatic, full catalog)
Trigger: Cron job detects new active season
Scope: All non-overridden variants for the tenant
Latency: 2-5 minutes for 20,000 variants
When: First day of new season (or within transition window)
Batch Recalculation
For scheduled, manual, and seasonal triggers, the engine processes variants in batches to avoid holding a database transaction for too long.
// src/services/priority/recalculator.ts
const BATCH_SIZE = 500;
export async function recalculateAllPriorities(
db: PrismaClient,
tenantId: string,
config: ScoringConfig,
options: { includeOverrides: boolean; productTypes?: string[] }
): Promise<RecalculationResult> {
let cursor: string | undefined;
let processed = 0;
let changed = 0;
while (true) {
// Fetch a batch of variants with their products and existing scores
const variants = await db.variant.findMany({
where: {
tenantId,
...(options.productTypes && {
product: { productType: { in: options.productTypes } },
}),
...(!options.includeOverrides && {
priorityScore: {
OR: [
{ override: false },
{ is: null }, // No score yet
],
},
}),
},
include: {
product: true,
priorityScore: true,
},
take: BATCH_SIZE,
...(cursor && { skip: 1, cursor: { id: cursor } }),
orderBy: { id: 'asc' },
});
if (variants.length === 0) break;
// Calculate new priorities for the batch
const updates: Array<{ variantId: string; result: PriorityResult }> = [];
for (const variant of variants) {
const result = calculatePriority(
{
id: variant.product.id,
productType: variant.product.productType,
vendor: variant.product.vendor,
tags: variant.product.tags,
status: variant.product.status,
createdAt: variant.product.createdAt,
},
{
id: variant.id,
shopifyVariantId: variant.shopifyVariantId,
inventoryQuantity: variant.inventoryQuantity,
},
config,
variant.priorityScore
);
// Only update if the score actually changed
if (!variant.priorityScore || variant.priorityScore.priority !== result.priority) {
updates.push({ variantId: variant.id, result });
changed++;
}
processed++;
}
// Batch upsert the changed scores
for (const { variantId, result } of updates) {
await db.priorityScore.upsert({
where: { variantId },
create: {
tenantId,
variantId,
priority: result.priority,
customLabel0: result.customLabel0,
customLabel1: result.customLabel1,
customLabel2: result.customLabel2,
customLabel3: result.customLabel3,
customLabel4: result.customLabel4,
calculatedAt: new Date(),
},
update: {
priority: result.priority,
customLabel0: result.customLabel0,
customLabel1: result.customLabel1,
customLabel2: result.customLabel2,
customLabel3: result.customLabel3,
customLabel4: result.customLabel4,
calculatedAt: new Date(),
},
});
}
cursor = variants[variants.length - 1].id;
}
return { processed, changed, unchanged: processed - changed };
}
Worked Examples with Real Nexus Data
Example 1: Winter Bestseller
Product: "Rebel Minds Puffer Jacket - Black"
Type: Men-Tops-Outerwear-Jackets-Puffer Jackets
Vendor: Rebel Minds
Tags: ["Men", "in-stock", "rebel-minds"]
Status: active
Created: 2025-09-15 (148 days ago)
Variant: Size L, inventory_quantity = 8
Layer 1 - Category: outerwear-heavy -> default 3
Layer 2 - Season: Winter + outerwear-heavy -> 5
Layer 3 - Inventory: 8 units (in stock, no modifier) -> no change
Layer 4 - Tags: "in-stock" -> +1 = 6
Layer 5 - New arrival: 148 days > 30 -> no boost
Clamp: 6 -> 5 (capped at 5)
RESULT: priority=5, source=seasonal
Labels: priority-5 | winter | outerwear-heavy | in-stock | off-brand
Example 2: Summer Clearance Item
Product: "Generic Mesh Shorts - Red"
Type: Men-Bottoms-Shorts-Mesh
Vendor: Black Keys
Tags: ["Men", "Sale", "SEMI25SALE"]
Status: active
Created: 2025-03-01 (346 days ago)
Variant: Size M, inventory_quantity = 3
Layer 1 - Category: shorts -> default 3
Layer 2 - Season: Winter + shorts -> 0
Layer 3 - Inventory: 3 units (< 5, low stock) -> -1 = -1
Layer 4 - Tags: "Sale" -> -1 = -2
Layer 5 - New arrival: 346 days > 30 -> no boost
Clamp: -2 -> 0 (floored at 0)
RESULT: priority=0, source=seasonal
Labels: priority-0 | winter | shorts | clearance | off-brand
Example 3: New Arrival Name Brand
Product: "Psycho Bunny Classic Polo - Navy"
Type: Men-Tops-T-Shirts
Vendor: Psycho Bunny
Tags: ["Men", "NAME BRAND", "in-stock", "psycho-bunny"]
Status: active
Created: 2026-01-28 (13 days ago)
Variant: Size M, inventory_quantity = 15
Layer 1 - Category: t-shirts -> default 3
Layer 2 - Season: Winter + t-shirts -> 2
Layer 3 - Inventory: 15 units (in stock, no modifier) -> no change
Layer 4 - Tags: "NAME BRAND" -> +1 = 3, "in-stock" -> +1 = 4
Layer 5 - New arrival: 13 days <= 30 -> boost to min 5 = 5
Clamp: 5 (within range)
RESULT: priority=5, source=new-arrival
Labels: priority-5 | winter | t-shirts | new-arrival | name-brand
Example 4: Dead Stock Override
Product: "Old Season Graphic Tee"
Type: Men-Tops-T-Shirts
Vendor: Nexus Clothing
Tags: ["Men", "DEAD50", "archived", "Sale"]
Status: archived
Variant: Size XL, inventory_quantity = 2
Layer 1 - Category: t-shirts -> default 3
Layer 2 - Season: Winter + t-shirts -> 2
Layer 3 - Inventory: 2 units (< 5, low stock) -> -1 = 1
Layer 4 - Tags: "DEAD50" -> OVERRIDE to 0 (immediate return)
RESULT: priority=0, source=tag-override
Labels: priority-0 | winter | t-shirts | dead-stock | store-brand
Example 5: Merchant Manual Override
Product: "Ethika Boxer Brief - Signature"
Type: Men-Underwear
Vendor: Ethika
Tags: ["Men", "NAME BRAND"]
Status: active
Existing: override=true, priority=4, reason="Top seller in store"
Variant: Size M, inventory_quantity = 45
Layer 0 - Manual override detected -> return existing score immediately
RESULT: priority=4, source=manual
Labels: priority-4 | winter | underwear-socks | in-stock | name-brand
Summary
The priority scoring engine evaluates six layers in a fixed order: category rules, season modifiers, inventory modifiers, tag modifiers, new arrival boost, and manual override. The engine produces a single 0-5 score and five custom label strings per variant, ready for the Google Sheets supplemental feed.
The design is deliberately deterministic: given the same product data, variant inventory, tags, season, and rules configuration, the engine always produces the same output. This makes the system predictable, testable, and debuggable. The appliedRules array in the result provides a complete audit trail of every layer that influenced the final score, which is invaluable for answering the question “why does this product have priority 3?”