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 GroupProduct Types IncludedProduct CountDefault Priority
1t-shirtsMen-Tops-T-Shirts, Boys-Tops-T-Shirts, Men-Tops-Crop-Top, Men-Tops-Tank Tops, T-Shirt1,3633
2long-sleeve-topsMen-Tops-T-Shirts-Long Sleeve543
3jeans-pantsMen-Bottoms-Pants-Jeans, Men-Bottoms-Stacked Jeans, Boys-Bottoms-Jeans, Men-Bottoms-Pants-Track Pants, Men-Bottoms-Pants-Cargo, Men-Bottoms-Stacked Track Pants9114
4sweatpantsMen-Bottoms-Pants-Sweatpants, Men-Bottoms-Stacked Sweatpants943
5shortsMen-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-Trunks3153
6swim-shortsMen-Bottoms-Shorts-Swim-Shorts402
7hoodies-sweatshirtsMen-Tops-Hoodies & Sweatshirts, Boys-Tops-Hoodies, Sweatshirts & Hoodies2643
8outerwear-heavyMen-Tops-Outerwear-Jackets-Puffer Jackets, Men-Tops-Outerwear-Jackets-Coats-Shearling723
9outerwear-mediumMen-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-Jackets2293
10outerwear-lightMen-Tops-Outerwear-Jackets-Track Jackets, Men-Tops-Outerwear-Jackets-Windbreaker393
11headwear-capsHeadwear-Baseball-Fitted, Headwear-Baseball-Dad Hat, Headwear-Baseball-Snapback, Headwear-Baseball-Low Profile Fitted7773
12headwear-coldHeadwear-Knit Beanies, Balaclavas1712
13headwear-summerHeadwear-Bucket Hat512
14joggersMen-Bottoms-Joggers, Boys-Bottoms-Joggers863
15footwear-sandalsMen-Footwear-Sandals & Slides582
16footwear-shoesMen-Footwear-Shoes, Women-Footwear-Shoes483
17underwear-socksMen-Underwear, Women-Underwear, Socks, Boys-Bottoms-Underwear5232
18accessoriesAccessories, Accessories-Bags, Accessories-Bags-Duffle Bags, Accessories-Bags-Smell Proof Bags, Accessories-Bags-Crossbody bags, Accessories-Jewelry, Accessories-Wallet Chains, Belts3502
19women-apparelWomen-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 Sets802
20excludeBath & Body, Household Cleaning Supplies-Cloth, Household Cleaning Supplies-Steel, Household Cleaning Supplies-Sponge, Gift Cards, Insurance, UpCart - Shipping Protection, Test Category, Sample~500

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.

ConditionThresholdEffectReason
Dead stockinventory_quantity = 0Override to 0Cannot fulfill orders
Low inventoryinventory_quantity < 5Adjustment: -1Reduce spend, risk of stockout
In stockinventory_quantity >= 5No changeNormal operation
Overstockinventory_quantity > 50Adjustment: -1Already 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

TagTypeValueReasonProducts Affected
archivedOverride0Archived products must not be advertised3,130
DEAD50Override0Dead stock at 50% off, exclude from ads615
warning_inv_1Adjustment-1Low inventory warning from Shopify3,619
warning_invAdjustment-1General inventory warning1,372
in-stockAdjustment+1Available and ready to ship930
NAME BRANDAdjustment+1Premium brand, higher expected ROAS2,328
SaleAdjustment-1Already discounted, lower margin1,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).

SettingDefaultDescription
newArrivalDays30Days since product creation to qualify
newArrivalPriority5Minimum 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.

LabelFormatSourceExample Values
custom_label_0priority-{score}Calculated prioritypriority-0 through priority-5
custom_label_1{season}Current active seasonwinter, spring, summer, fall
custom_label_2{category_group}Category resolverouterwear-heavy, t-shirts, jeans-pants
custom_label_3{inventory_status}Inventory + tags analysisnew-arrival, in-stock, low-inventory, clearance, dead-stock
custom_label_4{brand_tier}Vendor + tag analysisname-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?”