Chapter 17: Seasonal Automation

Why Seasonal Automation Matters

A retailer like Nexus Clothing sells products with dramatically different demand curves throughout the year. Puffer jackets that fly off shelves in December become dead weight in June. Mesh shorts that deserve every advertising dollar in July should receive zero spend in January. Without seasonal automation, a merchant must manually adjust priorities four times per year for every product category – a process that is easy to forget, hard to get right, and impossible to optimize at scale.

AdPriority’s seasonal automation (available on the Growth tier and above) eliminates this manual work. The system detects the current season, looks up the category-season priority matrix, and recalculates every variant’s score automatically. Merchants can customize the calendar, add micro-seasons, and preview changes before they take effect.

SEASONAL IMPACT ON NEXUS CATALOG
==================================

  WINTER (Nov-Feb)                       SUMMER (May-Aug)
  ==================                     ==================
  Puffer Jackets:  5 (PUSH HARD)        Puffer Jackets:  0 (EXCLUDE)
  Hoodies:         5 (PUSH HARD)        Hoodies:         1 (MINIMAL)
  Beanies:         5 (PUSH HARD)        Beanies:         0 (EXCLUDE)
  Jeans:           4 (STRONG)           Jeans:           3 (NORMAL)
  T-Shirts:        2 (LOW)             T-Shirts:        5 (PUSH HARD)
  Shorts:          0 (EXCLUDE)         Shorts:          5 (PUSH HARD)
  Sandals:         0 (EXCLUDE)         Sandals:         5 (PUSH HARD)
  Swim Shorts:     0 (EXCLUDE)         Swim Shorts:     5 (PUSH HARD)

  Budget impact: ~40% of catalog priorities change per season transition
  Products affected: ~1,500 of 2,425 active products

Default Season Calendar

Every new tenant starts with a default four-season calendar based on Northern Hemisphere retail patterns. The calendar defines month ranges for each season, which the automation engine uses to determine the currently active season.

Default Season Definitions

SeasonStart MonthEnd MonthDurationKey Retail Events
WinterNovember (11)February (2)4 monthsHolidays, New Year, Valentine’s Day
SpringMarch (3)April (4)2 monthsSpring Break, Easter
SummerMay (5)August (8)4 monthsMemorial Day, July 4th, Back-to-School prep
FallSeptember (9)October (10)2 monthsBack-to-School, Labor Day, early holiday prep

Annual Timeline

ANNUAL SEASON CALENDAR
=======================

  JAN    FEB    MAR    APR    MAY    JUN    JUL    AUG    SEP    OCT    NOV    DEC
  |______|______|______|______|______|______|______|______|______|______|______|______|
  |   WINTER    |  SPRING   |         SUMMER          |   FALL   |   WINTER    |
  |             |           |                          |          |             |
  |  Jackets 5  | Trans-    |  Shorts 5                | Jeans 5  |  Jackets 5  |
  |  Hoodies 5  | ition     |  T-Shirts 5              | Hoodies 5|  Hoodies 5  |
  |  Shorts 0   | period    |  Sandals 5               | Shorts 1 |  Shorts 0   |
  |             |           |  Jackets 0               |          |             |
  |_____________|___________|__________________________|__________|_____________|
       ^             ^              ^              ^          ^           ^
     peak          gradual        peak          gradual    gradual      peak
    winter         warm-up       summer         cool-down   prep       winter

Why These Dates

The default dates are based on when retail demand shifts, not astronomical seasons. Winter starts in November (not December 21) because holiday shopping begins before Thanksgiving. Summer starts in May because warm-weather products begin selling with Memorial Day sales. Fall is compressed to two months because the transition from summer to winter prep is fast in streetwear retail.


Category-Season Priority Matrix

The full priority matrix defines the exact score for each of the 20 category groups in each of the four seasons. This matrix is stored in the seasonal_calendars.category_overrides JSON column and loaded by the scoring engine during recalculation.

Complete Matrix

Category GroupWinterSpringSummerFallDefaultRationale
outerwear-heavy51043Peak in cold months, excluded in summer
outerwear-medium43043Denim/varsity jackets relevant fall through winter
outerwear-light24133Windbreakers and track jackets peak in spring
hoodies-sweatshirts53153Strong in cold months, minimal in summer
headwear-cold51032Beanies and balaclavas peak in winter
headwear-caps33333Year-round demand, no seasonal adjustment
headwear-summer03422Bucket hats peak in summer
jeans-pants44354Year-round staple, peak in back-to-school fall
sweatpants43143Loungewear peaks in cold months
joggers43243Active + loungewear, cold-weather bias
long-sleeve-tops43143Layering piece, cold-weather demand
t-shirts24533Peak in summer, lowest in winter
shorts03513Excluded in winter, peak in summer
swim-shorts02502Pure summer product
footwear-sandals02502Pure summer product
footwear-shoes33333Year-round, no seasonal adjustment
underwear-socks22222Year-round basics, always low priority
accessories22222Year-round, no seasonal adjustment
women-apparel23322Slight spring/summer bump
exclude00000Always excluded regardless of season

Visual Heat Map

SEASONAL PRIORITY HEAT MAP
===========================
(0 = excluded, 5 = push hard)

                    WINTER  SPRING  SUMMER  FALL
                    ------  ------  ------  ----
outerwear-heavy       5       1       0      4     ████░    ░      .     ███
outerwear-medium      4       3       0      4     ███     ██      .     ███
outerwear-light       2       4       1      3     █      ███      ░     ██
hoodies-sweatshirts   5       3       1      5     ████░  ██       ░     ████░
headwear-cold         5       1       0      3     ████░   ░       .     ██
jeans-pants           4       4       3      5     ███    ███      ██    ████░
sweatpants            4       3       1      4     ███     ██      ░     ███
joggers               4       3       2      4     ███     ██      █     ███
long-sleeve-tops      4       3       1      4     ███     ██      ░     ███
headwear-caps         3       3       3      3     ██      ██      ██    ██
t-shirts              2       4       5      3     █      ███     ████░  ██
shorts                0       3       5      1     .       ██     ████░   ░
swim-shorts           0       2       5      0     .       █      ████░   .
footwear-sandals      0       2       5      0     .       █      ████░   .
headwear-summer       0       3       4      2     .       ██      ███    █
underwear-socks       2       2       2      2     █       █       █      █
accessories           2       2       2      2     █       █       █      █

Legend: . = 0 (exclude)  ░ = 1 (minimal)  █ = 2-3  ██ = 3-4  ████░ = 5

Season Transition Logic

Season Detection

The active season is determined by checking the current date against the tenant’s seasonal calendar entries. The algorithm handles wrap-around months (winter spans November through February, crossing the year boundary).

// src/services/priority/seasonal.ts

import { Season, SeasonalCalendar } from '@prisma/client';

interface SeasonInfo {
  currentSeason: Season;
  calendarId: string;
  daysRemaining: number;
  nextSeason: Season;
  nextTransitionDate: Date;
}

/**
 * Determine the currently active season for a tenant.
 * Handles month wrap-around (e.g., winter = Nov-Feb).
 */
export function detectCurrentSeason(
  calendars: SeasonalCalendar[],
  today: Date = new Date()
): SeasonInfo {
  const currentMonth = today.getMonth() + 1; // 1-12

  for (const cal of calendars) {
    if (isMonthInRange(currentMonth, cal.startMonth, cal.endMonth)) {
      const nextCal = getNextSeason(calendars, cal.season);
      const transitionDate = getTransitionDate(nextCal, today);
      const daysRemaining = daysBetween(today, transitionDate);

      return {
        currentSeason: cal.season,
        calendarId: cal.id,
        daysRemaining,
        nextSeason: nextCal.season,
        nextTransitionDate: transitionDate,
      };
    }
  }

  // Fallback: if no season matches (should not happen with valid data)
  throw new Error('No season matches the current date. Check calendar configuration.');
}

/**
 * Check if a month falls within a range, handling wrap-around.
 * Example: isMonthInRange(1, 11, 2) => true (January is in Nov-Feb)
 */
function isMonthInRange(month: number, start: number, end: number): boolean {
  if (start <= end) {
    // Simple range: Mar(3) to Apr(4)
    return month >= start && month <= end;
  } else {
    // Wrap-around range: Nov(11) to Feb(2)
    return month >= start || month <= end;
  }
}

/**
 * Get the next season in the cycle.
 */
function getNextSeason(
  calendars: SeasonalCalendar[],
  current: Season
): SeasonalCalendar {
  const order: Season[] = ['winter', 'spring', 'summer', 'fall'];
  const currentIdx = order.indexOf(current);
  const nextSeason = order[(currentIdx + 1) % 4];
  return calendars.find(c => c.season === nextSeason)!;
}

/**
 * Calculate the date of the next season transition.
 */
function getTransitionDate(nextCal: SeasonalCalendar, today: Date): Date {
  const year = today.getFullYear();
  let transitionDate = new Date(year, nextCal.startMonth - 1, 1);

  // If the transition date is in the past, it must be next year
  if (transitionDate <= today) {
    transitionDate = new Date(year + 1, nextCal.startMonth - 1, 1);
  }

  return transitionDate;
}

function daysBetween(d1: Date, d2: Date): number {
  return Math.ceil((d2.getTime() - d1.getTime()) / (1000 * 60 * 60 * 24));
}

Transition Detection Cron Job

A cron job runs daily at midnight and checks whether a season transition has occurred. When a new season is detected, it triggers a full priority recalculation for the tenant.

// src/scheduler/seasonalTransition.ts

import cron from 'node-cron';
import { prisma } from '../database/client';
import { detectCurrentSeason } from '../services/priority/seasonal';
import { recalculateAllPriorities } from '../services/priority/recalculator';
import { buildScoringConfig } from '../services/priority/configBuilder';

// Store the last known season per tenant to detect transitions
const lastKnownSeason = new Map<string, string>();

/**
 * Daily season transition check.
 * Runs at midnight UTC. For each active tenant, checks if the season
 * has changed since the last check and triggers recalculation if so.
 */
cron.schedule('0 0 * * *', async () => {
  console.log('[seasonal] Running daily season transition check');

  const tenants = await prisma.tenant.findMany({
    where: { status: 'active' },
    include: { seasonalCalendars: true },
  });

  for (const tenant of tenants) {
    try {
      const seasonInfo = detectCurrentSeason(tenant.seasonalCalendars);
      const previousSeason = lastKnownSeason.get(tenant.id);

      if (previousSeason && previousSeason !== seasonInfo.currentSeason) {
        console.log(
          `[seasonal] Season transition detected for ${tenant.shopifyShopDomain}: ` +
          `${previousSeason} -> ${seasonInfo.currentSeason}`
        );

        // Log the transition
        await prisma.syncLog.create({
          data: {
            tenantId: tenant.id,
            syncType: 'gmc',
            status: 'started',
            startedAt: new Date(),
          },
        });

        // Build scoring config with the new season's overrides
        const config = await buildScoringConfig(tenant.id, seasonInfo.currentSeason);

        // Recalculate all priorities
        const result = await recalculateAllPriorities(
          prisma,
          tenant.id,
          config,
          { includeOverrides: false }
        );

        console.log(
          `[seasonal] Recalculation complete for ${tenant.shopifyShopDomain}: ` +
          `${result.changed} changed out of ${result.processed} processed`
        );
      }

      lastKnownSeason.set(tenant.id, seasonInfo.currentSeason);

    } catch (error) {
      console.error(
        `[seasonal] Error checking season for ${tenant.shopifyShopDomain}:`,
        error
      );
    }
  }
});

Gradual Season Transitions

Season changes do not happen abruptly in retail. The demand curve for puffer jackets does not drop from peak to zero on March 1. AdPriority supports gradual transitions with a configurable window before and after the official season boundary. During the transition window, products receive blended priorities.

Transition Window

GRADUAL TRANSITION: Winter -> Spring
======================================

  Feb 15          Mar 1            Mar 15
    |               |                |
    |  TRANSITION   |  TRANSITION    |
    |  WINDOW START | (official)     | WINDOW END
    |               |                |
    v               v                v
  +---------------+----------------+
  |  Ramp Down    |   Ramp Up      |
  |  Winter items |   Spring items |
  +---------------+----------------+

  Outerwear-heavy during transition:
    Feb 15:  priority = 5 (full winter)
    Feb 22:  priority = 4 (blended: 75% winter + 25% spring)
    Mar 1:   priority = 3 (blended: 50% winter + 50% spring)
    Mar 8:   priority = 2 (blended: 25% winter + 75% spring)
    Mar 15:  priority = 1 (full spring value)

  Shorts during transition:
    Feb 15:  priority = 0 (full winter, excluded)
    Feb 22:  priority = 1 (blended)
    Mar 1:   priority = 2 (blended)
    Mar 8:   priority = 2 (blended)
    Mar 15:  priority = 3 (full spring value)

Transition Window Configuration

SettingDefaultDescription
transitionDaysBefore14Days before official transition to start blending
transitionDaysAfter14Days after official transition to finish blending
transitionEnabledtrueWhether to use gradual transitions

Blended Priority Calculation

// src/services/priority/transition.ts

interface TransitionState {
  inTransition: boolean;
  fromSeason: string;
  toSeason: string;
  progress: number;  // 0.0 (fully "from" season) to 1.0 (fully "to" season)
}

/**
 * Calculate the transition state for a given date.
 * Returns whether we are in a transition window and the blending progress.
 */
export function getTransitionState(
  calendars: SeasonalCalendar[],
  today: Date,
  transitionDaysBefore: number = 14,
  transitionDaysAfter: number = 14
): TransitionState | null {
  const currentMonth = today.getMonth() + 1;
  const currentDay = today.getDate();

  // Find the nearest transition boundary
  for (const cal of calendars) {
    const transitionDate = new Date(
      today.getFullYear(),
      cal.startMonth - 1,
      1
    );

    const daysDiff = daysBetween(today, transitionDate);

    // Check if we are within the transition window
    if (daysDiff > 0 && daysDiff <= transitionDaysBefore) {
      // Before the transition: ramping down current season
      const previousCal = getPreviousSeason(calendars, cal.season);
      const totalWindow = transitionDaysBefore + transitionDaysAfter;
      const progress = (transitionDaysBefore - daysDiff) / totalWindow;

      return {
        inTransition: true,
        fromSeason: previousCal.season,
        toSeason: cal.season,
        progress,
      };
    }

    if (daysDiff <= 0 && Math.abs(daysDiff) <= transitionDaysAfter) {
      // After the transition: ramping up new season
      const previousCal = getPreviousSeason(calendars, cal.season);
      const totalWindow = transitionDaysBefore + transitionDaysAfter;
      const progress = (transitionDaysBefore + Math.abs(daysDiff)) / totalWindow;

      return {
        inTransition: true,
        fromSeason: previousCal.season,
        toSeason: cal.season,
        progress,
      };
    }
  }

  return null;
}

/**
 * Blend two seasonal priorities based on transition progress.
 */
export function blendSeasonalPriority(
  fromPriority: number,
  toPriority: number,
  progress: number  // 0.0 = fully "from", 1.0 = fully "to"
): number {
  const blended = fromPriority * (1 - progress) + toPriority * progress;
  return Math.round(blended);
}

Transition in Practice

Consider the Winter-to-Spring transition on March 1, with a 14-day window on each side (February 15 through March 15).

DateProgressOuterwear-HeavyShortsT-Shirts
Feb 14– (no transition)5 (winter)0 (winter)2 (winter)
Feb 150.00502
Feb 180.11502
Feb 220.25413
Feb 250.36413
Mar 10.50323
Mar 40.61223
Mar 80.75224
Mar 110.86134
Mar 151.00134
Mar 16– (no transition)1 (spring)3 (spring)4 (spring)

Customizable Calendars

Custom Season Dates

Merchants on the Growth tier and above can adjust the start and end months of each season through the Settings UI. This accommodates regional differences and business-specific patterns. A store in Florida might start summer a month earlier; a store selling ski gear might extend winter through March.

// Example: Custom calendar for a Florida-based store
const floridaCalendar = [
  { season: 'winter',  startMonth: 12, endMonth: 1  },  // Short winter
  { season: 'spring',  startMonth: 2,  endMonth: 3  },  // Early spring
  { season: 'summer',  startMonth: 4,  endMonth: 9  },  // Long summer
  { season: 'fall',    startMonth: 10, endMonth: 11 },   // Short fall
];

Micro-Seasons

Beyond the four standard seasons, merchants can define micro-seasons for specific retail events. A micro-season temporarily overrides the active season’s priority matrix for a defined date range.

Micro-SeasonTypical DatesDurationCategory Impact
Back to SchoolAug 1 - Sep 156 weeksJeans +2, T-shirts +1, Hoodies +1
Holiday RushNov 20 - Dec 255 weeksAll categories +1, gift items +2
Valentine’s DayFeb 7 - Feb 141 weekAccessories +2
Spring BreakMar 10 - Mar 2010 daysShorts +1, Swim shorts +2
Clearance WeekEnd of each season1-2 weeksSale items -1, remaining season items -2

Micro-Season Data Model

Micro-seasons are stored as category rules with date ranges, using the category_rules table.

// Example: Creating a "Back to School" micro-season rule
await prisma.categoryRule.createMany({
  data: [
    {
      tenantId: tenant.id,
      productTypePattern: 'jeans-pants',
      season: 'summer', // Active during summer season
      basePriority: 5,  // Override summer's default 3 for jeans
      modifiers: {
        microSeason: 'back-to-school',
        startDate: '2026-08-01',
        endDate: '2026-09-15',
        reason: 'Back to School promotion'
      },
    },
    {
      tenantId: tenant.id,
      productTypePattern: 'hoodies-sweatshirts',
      season: 'summer',
      basePriority: 3,  // Override summer's default 1 for hoodies
      modifiers: {
        microSeason: 'back-to-school',
        startDate: '2026-08-01',
        endDate: '2026-09-15',
        reason: 'Early fall prep for Back to School'
      },
    },
  ],
});

Seasonal Rules Examples with Real Nexus Data

Scenario 1: Winter Season Active (Current)

Nexus Clothing in February 2026. Winter season is active with the standard matrix.

NEXUS CATALOG - WINTER PRIORITIES
===================================

Category                    | Products | Priority | Budget Impact
----------------------------+----------+----------+--------------
Outerwear (Heavy)           |    72    |    5     | Maximum spend
Hoodies & Sweatshirts       |   264    |    5     | Maximum spend
Headwear (Beanies/Balacl.)  |   171    |    5     | Maximum spend
Jeans & Pants               |   911    |    4     | High spend
Sweatpants                  |    94    |    4     | High spend
Joggers                     |    86    |    4     | High spend
Long Sleeve Tops            |    54    |    4     | High spend
Outerwear (Medium)          |   229    |    4     | High spend
Headwear (Caps)             |   777    |    3     | Normal spend
Footwear (Shoes)            |    48    |    3     | Normal spend
T-Shirts                    | 1,363    |    2     | Low spend
Underwear & Socks           |   523    |    2     | Low spend
Accessories                 |   350    |    2     | Low spend
Women's Apparel             |    80    |    2     | Low spend
Shorts (all types)          |   315    |    0     | EXCLUDED
Swim Shorts                 |    40    |    0     | EXCLUDED
Sandals & Slides            |    58    |    0     | EXCLUDED
Headwear (Bucket Hats)      |    51    |    0     | EXCLUDED
Excluded (Gift Cards etc.)  |   ~50    |    0     | EXCLUDED

Total active in ads:  ~3,911 products (excludes 514 at priority 0)
Priority 5 ad push:     507 products (heavy outerwear, hoodies, beanies)
Priority 4 balanced:   1,374 products (jeans, sweatpants, joggers, medium outerwear)

Scenario 2: Transition to Spring (March 1)

On March 1, the automation engine detects the season change and recalculates all priorities.

WINTER -> SPRING TRANSITION IMPACT
====================================

Category                    | Winter | Spring | Change | Products
----------------------------+--------+--------+--------+---------
Outerwear (Heavy)           |   5    |   1    |  -4    |    72
Outerwear (Medium)          |   4    |   3    |  -1    |   229
Headwear (Beanies/Balacl.)  |   5    |   1    |  -4    |   171
Hoodies & Sweatshirts       |   5    |   3    |  -2    |   264
Sweatpants                  |   4    |   3    |  -1    |    94
Long Sleeve Tops            |   4    |   3    |  -1    |    54
Joggers                     |   4    |   3    |  -1    |    86
                            |        |        |        |
Shorts (all types)          |   0    |   3    |  +3    |   315
Headwear (Bucket Hats)      |   0    |   3    |  +3    |    51
Swim Shorts                 |   0    |   2    |  +2    |    40
Sandals & Slides            |   0    |   2    |  +2    |    58
T-Shirts                    |   2    |   4    |  +2    | 1,363
Outerwear (Light)           |   2    |   4    |  +2    |    39
                            |        |        |        |
Jeans & Pants               |   4    |   4    |   0    |   911
Headwear (Caps)             |   3    |   3    |   0    |   777
Footwear (Shoes)            |   3    |   3    |   0    |    48
Underwear & Socks           |   2    |   2    |   0    |   523
Accessories                 |   2    |   2    |   0    |   350

SUMMARY:
  Products with INCREASED priority:  1,866
  Products with DECREASED priority:    970
  Products UNCHANGED:               2,609
  Products newly INCLUDED:            464 (were priority 0)
  Products newly EXCLUDED:              0 (nothing goes to 0 in Spring)

Scenario 3: Summer Peak (July)

Deep summer. Warm-weather products are at maximum priority. This is when the seasonal engine delivers the most value, ensuring every ad dollar goes toward products people actually want to buy.

NEXUS CATALOG - SUMMER PRIORITIES (JULY)
==========================================

PRIORITY 5 (Push Hard) - Maximum ad spend:
  Shorts (all types):       315 products
  T-Shirts:               1,363 products
  Swim Shorts:               40 products
  Sandals & Slides:          58 products
  ---
  TOTAL PUSH HARD:        1,776 products

PRIORITY 4 (Strong):
  Bucket Hats:               51 products

PRIORITY 3 (Normal):
  Jeans & Pants:            911 products
  Headwear (Caps):          777 products
  Footwear (Shoes):          48 products
  Women's Apparel:           80 products

PRIORITY 2 (Low):
  Underwear & Socks:        523 products
  Accessories:              350 products
  Joggers:                   86 products
  Outerwear (Light):         39 products

PRIORITY 1 (Minimal):
  Hoodies & Sweatshirts:    264 products
  Sweatpants:                94 products
  Long Sleeve Tops:          54 products

PRIORITY 0 (Excluded):
  Outerwear (Heavy):         72 products
  Outerwear (Medium):       229 products
  Headwear (Beanies):       171 products
  Excluded (Gift Cards):    ~50 products
  ---
  TOTAL EXCLUDED:           522 products

Scenario 4: Back-to-School Micro-Season (August)

A merchant activates a “Back to School” micro-season from August 1 through September 15. This overrides certain summer priorities to capture the back-to-school shopping rush.

BACK TO SCHOOL OVERRIDE (Aug 1 - Sep 15)
==========================================

Base: Summer season priorities
Override: Back-to-School micro-season adjustments

Category              | Summer | BTS Override | Net  | Reason
----------------------+--------+--------------+------+----------------------------
Jeans & Pants         |   3    |     +2       |  5   | Kids and teens buying jeans
Hoodies & Sweatshirts |   1    |     +2       |  3   | Early fall wardrobe prep
T-Shirts              |   5    |      0       |  5   | Still summer, remains high
Shorts                |   5    |     -1       |  4   | Starting to decline
Outerwear (Medium)    |   0    |     +2       |  2   | Denim jackets for school
Headwear (Caps)       |   3    |     +1       |  4   | Back-to-school accessory

Products affected by BTS micro-season: ~2,180
Duration: 46 days
Auto-reverts: September 16 (returns to standard fall priorities)

Implementation Details

Scoring Config Builder

The scoring config builder assembles the complete configuration needed by the scoring engine, including seasonal overrides and transition blending.

// src/services/priority/configBuilder.ts

import { PrismaClient, Season } from '@prisma/client';
import { detectCurrentSeason } from './seasonal';
import { getTransitionState, blendSeasonalPriority } from './transition';

export async function buildScoringConfig(
  tenantId: string,
  overrideSeason?: Season
): Promise<ScoringConfig> {
  const tenant = await prisma.tenant.findUniqueOrThrow({
    where: { id: tenantId },
    include: {
      seasonalCalendars: true,
      categoryRules: true,
    },
  });

  // Detect current season
  const seasonInfo = detectCurrentSeason(tenant.seasonalCalendars);
  const activeSeason = overrideSeason || seasonInfo.currentSeason;

  // Get the seasonal overrides for the active season
  const activeCalendar = tenant.seasonalCalendars.find(
    c => c.season === activeSeason
  );
  let seasonOverrides = (activeCalendar?.categoryOverrides as Record<string, number>) || {};

  // Check for gradual transition
  const transition = getTransitionState(tenant.seasonalCalendars, new Date());
  if (transition && !overrideSeason) {
    const fromCalendar = tenant.seasonalCalendars.find(
      c => c.season === transition.fromSeason
    );
    const toCalendar = tenant.seasonalCalendars.find(
      c => c.season === transition.toSeason
    );

    if (fromCalendar && toCalendar) {
      const fromOverrides = (fromCalendar.categoryOverrides as Record<string, number>) || {};
      const toOverrides = (toCalendar.categoryOverrides as Record<string, number>) || {};

      // Blend priorities for all category groups
      const allGroups = new Set([
        ...Object.keys(fromOverrides),
        ...Object.keys(toOverrides),
      ]);

      seasonOverrides = {};
      for (const group of allGroups) {
        const fromPriority = fromOverrides[group] ?? 3;
        const toPriority = toOverrides[group] ?? 3;
        seasonOverrides[group] = blendSeasonalPriority(
          fromPriority, toPriority, transition.progress
        );
      }
    }
  }

  // Build tag modifiers from category rules
  const tagModifiers: Record<string, { override?: number; adjustment?: number }> = {};
  for (const rule of tenant.categoryRules) {
    const mods = rule.modifiers as any;
    if (mods?.tagAdjustments) {
      for (const [tag, modifier] of Object.entries(mods.tagAdjustments)) {
        tagModifiers[tag] = modifier as any;
      }
    }
  }

  return {
    currentSeason: activeSeason,
    seasonOverrides,
    defaultPriority: 3,
    newArrivalDays: 30,
    newArrivalPriority: 5,
    tagModifiers,
    inventoryModifiers: {
      zeroStockOverride: 0,
      lowStockThreshold: 5,
      lowStockAdjustment: -1,
      overstockThreshold: 50,
      overstockAdjustment: -1,
    },
  };
}

Notification on Season Transition

When a season transition is detected, the system creates a notification visible in the Shopify Admin dashboard.

// src/services/notifications/seasonalNotification.ts

interface TransitionNotification {
  type: 'season_transition';
  title: string;
  body: string;
  severity: 'info';
  data: {
    fromSeason: string;
    toSeason: string;
    productsAffected: number;
    increases: number;
    decreases: number;
  };
}

export function buildTransitionNotification(
  fromSeason: string,
  toSeason: string,
  result: RecalculationResult
): TransitionNotification {
  return {
    type: 'season_transition',
    title: `Season changed: ${capitalize(fromSeason)} to ${capitalize(toSeason)}`,
    body: `${result.changed} product priorities were updated. ` +
          `${result.processed - result.changed} products unchanged. ` +
          `Scores will sync to Google Merchant Center on the next scheduled sync.`,
    severity: 'info',
    data: {
      fromSeason,
      toSeason,
      productsAffected: result.changed,
      increases: 0, // Populated by recalculator
      decreases: 0, // Populated by recalculator
    },
  };
}

function capitalize(s: string): string {
  return s.charAt(0).toUpperCase() + s.slice(1);
}

Testing the Seasonal Engine

Unit Test: Season Detection

// tests/unit/seasonal.test.ts

import { detectCurrentSeason } from '../../src/services/priority/seasonal';

const calendars = [
  { id: '1', tenantId: 't1', season: 'winter', startMonth: 11, endMonth: 2, name: 'Winter', categoryOverrides: {}, createdAt: new Date(), updatedAt: new Date() },
  { id: '2', tenantId: 't1', season: 'spring', startMonth: 3,  endMonth: 4, name: 'Spring', categoryOverrides: {}, createdAt: new Date(), updatedAt: new Date() },
  { id: '3', tenantId: 't1', season: 'summer', startMonth: 5,  endMonth: 8, name: 'Summer', categoryOverrides: {}, createdAt: new Date(), updatedAt: new Date() },
  { id: '4', tenantId: 't1', season: 'fall',   startMonth: 9,  endMonth: 10, name: 'Fall',  categoryOverrides: {}, createdAt: new Date(), updatedAt: new Date() },
] as any[];

describe('Season Detection', () => {
  it('detects winter in January', () => {
    const result = detectCurrentSeason(calendars, new Date('2026-01-15'));
    expect(result.currentSeason).toBe('winter');
    expect(result.nextSeason).toBe('spring');
  });

  it('detects spring in March', () => {
    const result = detectCurrentSeason(calendars, new Date('2026-03-15'));
    expect(result.currentSeason).toBe('spring');
    expect(result.nextSeason).toBe('summer');
  });

  it('detects summer in July', () => {
    const result = detectCurrentSeason(calendars, new Date('2026-07-15'));
    expect(result.currentSeason).toBe('summer');
    expect(result.nextSeason).toBe('fall');
  });

  it('detects fall in October', () => {
    const result = detectCurrentSeason(calendars, new Date('2026-10-15'));
    expect(result.currentSeason).toBe('fall');
    expect(result.nextSeason).toBe('winter');
  });

  it('detects winter in November (start of winter)', () => {
    const result = detectCurrentSeason(calendars, new Date('2026-11-01'));
    expect(result.currentSeason).toBe('winter');
  });

  it('detects winter in February (end of winter)', () => {
    const result = detectCurrentSeason(calendars, new Date('2026-02-28'));
    expect(result.currentSeason).toBe('winter');
  });

  it('handles year boundary (December is winter)', () => {
    const result = detectCurrentSeason(calendars, new Date('2026-12-25'));
    expect(result.currentSeason).toBe('winter');
  });
});

Integration Test: Full Season Transition

// tests/integration/seasonTransition.test.ts

describe('Season Transition', () => {
  it('recalculates all priorities on winter to spring transition', async () => {
    // Setup: Create tenant with winter priorities
    const tenant = await createTestTenant();
    await seedWinterPriorities(tenant.id);

    // Verify initial state
    const winterScores = await getScoreDistribution(tenant.id);
    expect(winterScores.excludedCount).toBeGreaterThan(400); // shorts, sandals excluded

    // Act: Trigger spring transition
    const springConfig = await buildScoringConfig(tenant.id, 'spring');
    const result = await recalculateAllPriorities(
      prisma, tenant.id, springConfig, { includeOverrides: false }
    );

    // Assert: Spring priorities applied
    expect(result.changed).toBeGreaterThan(1000);

    // Shorts should now be priority 3 (were 0)
    const shortsScores = await getScoresForCategory(tenant.id, 'shorts');
    for (const score of shortsScores) {
      expect(score.priority).toBe(3);
    }

    // Heavy outerwear should now be priority 1 (were 5)
    const outerScores = await getScoresForCategory(tenant.id, 'outerwear-heavy');
    for (const score of outerScores) {
      expect(score.priority).toBe(1);
    }
  });
});

Summary

Seasonal automation is the feature that transforms AdPriority from a manual scoring tool into an intelligent, hands-off system. The automation engine detects season changes automatically, applies the category-season priority matrix, and recalculates every variant’s score in minutes. Gradual transitions prevent abrupt priority shifts, and customizable calendars with micro-seasons accommodate the unique rhythms of each merchant’s business.

For Nexus Clothing, seasonal automation means roughly 1,500 products automatically receive updated priorities four times per year, ensuring that winter jackets get maximum ad spend in December and zero spend in July, without the merchant lifting a finger. The back-to-school micro-season ensures jeans and hoodies receive a boost in August, capturing early fall demand while summer products are still strong. This level of automation is the core value proposition of the Growth tier and represents the difference between “nice to have” and “cannot live without” for seasonal retailers.