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 both the AdPriority ($49/mo) and AdPriority Pro ($149/mo) tiers) 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
| Season | Start Month | End Month | Duration | Key Retail Events |
|---|---|---|---|---|
| Winter | November (11) | February (2) | 4 months | Holidays, New Year, Valentine’s Day |
| Spring | March (3) | April (4) | 2 months | Spring Break, Easter |
| Summer | May (5) | August (8) | 4 months | Memorial Day, July 4th, Back-to-School prep |
| Fall | September (9) | October (10) | 2 months | Back-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 Group | Winter | Spring | Summer | Fall | Default | Rationale |
|---|---|---|---|---|---|---|
outerwear-heavy | 5 | 1 | 0 | 4 | 3 | Peak in cold months, excluded in summer |
outerwear-medium | 4 | 3 | 0 | 4 | 3 | Denim/varsity jackets relevant fall through winter |
outerwear-light | 2 | 4 | 1 | 3 | 3 | Windbreakers and track jackets peak in spring |
hoodies-sweatshirts | 5 | 3 | 1 | 5 | 3 | Strong in cold months, minimal in summer |
headwear-cold-weather | 5 | 1 | 0 | 3 | 2 | Beanies and balaclavas peak in winter |
headwear-caps | 3 | 3 | 3 | 3 | 3 | Year-round demand, no seasonal adjustment |
headwear-summer | 0 | 3 | 4 | 2 | 2 | Bucket hats peak in summer |
jeans-pants | 4 | 4 | 3 | 5 | 4 | Year-round staple, peak in back-to-school fall |
sweatpants | 4 | 3 | 1 | 4 | 3 | Loungewear peaks in cold months |
joggers | 4 | 3 | 2 | 4 | 3 | Active + loungewear, cold-weather bias |
long-sleeve-tops | 4 | 3 | 1 | 4 | 3 | Layering piece, cold-weather demand |
t-shirts | 2 | 4 | 5 | 3 | 3 | Peak in summer, lowest in winter |
shorts | 0 | 3 | 5 | 1 | 3 | Excluded in winter, peak in summer |
swim-shorts | 0 | 2 | 5 | 0 | 2 | Pure summer product |
footwear-sandals | 0 | 2 | 5 | 0 | 2 | Pure summer product |
footwear-shoes | 3 | 3 | 3 | 3 | 3 | Year-round, no seasonal adjustment |
underwear-socks | 2 | 2 | 2 | 2 | 2 | Year-round basics, always low priority |
accessories | 2 | 2 | 2 | 2 | 2 | Year-round, no seasonal adjustment |
women-apparel | 2 | 3 | 3 | 2 | 2 | Slight spring/summer bump |
exclude | 0 | 0 | 0 | 0 | 0 | Always 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-weather 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 Job (Bull + Redis)
A Bull queue 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. The last known season is persisted in the database (not in-memory) so that restarts do not cause missed or duplicate transitions.
// src/scheduler/seasonalTransition.ts
import { Queue, Worker } from 'bullmq';
import { redis } from '../config/redis';
import { prisma } from '../database/client';
import { detectCurrentSeason } from '../services/priority/seasonal';
import { recalculateAllPriorities } from '../services/priority/recalculator';
import { buildScoringConfig } from '../services/priority/configBuilder';
// Bull queue for seasonal transition checks
const seasonalQueue = new Queue('seasonal-transition', { connection: redis });
// Schedule: daily at midnight UTC
await seasonalQueue.add(
'check-transitions',
{},
{ repeat: { pattern: '0 0 * * *' } }
);
/**
* Daily season transition check.
* For each active tenant, checks if the season has changed since the
* last check (persisted in tenant.currentSeason column) and triggers
* recalculation if so.
*/
const worker = new Worker(
'seasonal-transition',
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);
// Read persisted season from DB, not an in-memory Map
const previousSeason = tenant.currentSeason;
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`
);
}
// Persist current season to DB so restarts do not lose state
await prisma.tenant.update({
where: { id: tenant.id },
data: { currentSeason: seasonInfo.currentSeason },
});
} catch (error) {
console.error(
`[seasonal] Error checking season for ${tenant.shopifyShopDomain}:`,
error
);
}
}
},
{ connection: redis }
);
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
| Setting | Default | Description |
|---|---|---|
transitionDaysBefore | 14 | Days before official transition to start blending |
transitionDaysAfter | 14 | Days after official transition to finish blending |
transitionEnabled | true | Whether 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).
| Date | Progress | Outerwear-Heavy | Shorts | T-Shirts |
|---|---|---|---|---|
| Feb 14 | – (no transition) | 5 (winter) | 0 (winter) | 2 (winter) |
| Feb 15 | 0.00 | 5 | 0 | 2 |
| Feb 18 | 0.11 | 5 | 0 | 2 |
| Feb 22 | 0.25 | 4 | 1 | 3 |
| Feb 25 | 0.36 | 4 | 1 | 3 |
| Mar 1 | 0.50 | 3 | 2 | 3 |
| Mar 4 | 0.61 | 2 | 2 | 3 |
| Mar 8 | 0.75 | 2 | 2 | 4 |
| Mar 11 | 0.86 | 1 | 3 | 4 |
| Mar 15 | 1.00 | 1 | 3 | 4 |
| Mar 16 | – (no transition) | 1 (spring) | 3 (spring) | 4 (spring) |
Customizable Calendars
Custom Season Dates
Merchants on either the AdPriority or AdPriority Pro tier 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-Season | Typical Dates | Duration | Category Impact |
|---|---|---|---|
| Back to School | Aug 1 - Sep 15 | 6 weeks | Jeans +2, T-shirts +1, Hoodies +1 |
| Holiday Rush | Nov 20 - Dec 25 | 5 weeks | All categories +1, gift items +2 |
| Valentine’s Day | Feb 7 - Feb 14 | 1 week | Accessories +2 |
| Spring Break | Mar 10 - Mar 20 | 10 days | Shorts +1, Swim shorts +2 |
| Clearance Week | End of each season | 1-2 weeks | Sale 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 included in both AdPriority ($49/mo) and AdPriority Pro ($149/mo) tiers and represents the difference between “nice to have” and “cannot live without” for seasonal retailers.
Industry Seasonal Templates
AdPriority ships with three pre-built seasonal templates that merchants can select during onboarding. Each template pre-fills the category-season priority matrix with industry-appropriate defaults.
Fashion / Apparel Template
Designed for streetwear, casual fashion, and general apparel retailers. This is the default template and matches the Nexus Clothing matrix documented above.
| Category Group | Winter | Spring | Summer | Fall |
|---|---|---|---|---|
outerwear-heavy | 5 | 1 | 0 | 4 |
hoodies-sweatshirts | 5 | 3 | 1 | 5 |
headwear-cold-weather | 5 | 1 | 0 | 3 |
jeans-pants | 4 | 4 | 3 | 5 |
t-shirts | 2 | 4 | 5 | 3 |
shorts | 0 | 3 | 5 | 1 |
swim-shorts | 0 | 2 | 5 | 0 |
footwear-sandals | 0 | 2 | 5 | 0 |
headwear-caps | 3 | 3 | 3 | 3 |
accessories | 2 | 2 | 2 | 2 |
Best for: Streetwear brands, fashion boutiques, multi-category clothing stores.
Sporting Goods / Outdoor Template
Designed for retailers selling sports equipment, outdoor gear, and athletic apparel. Priorities reflect activity-based seasonality (skiing in winter, water sports in summer, running year-round).
| Category Group | Winter | Spring | Summer | Fall |
|---|---|---|---|---|
ski-snowboard | 5 | 1 | 0 | 3 |
winter-apparel | 5 | 2 | 0 | 3 |
camping-hiking | 1 | 4 | 5 | 3 |
water-sports | 0 | 3 | 5 | 1 |
cycling | 1 | 5 | 4 | 3 |
running | 3 | 4 | 4 | 3 |
team-sports | 2 | 4 | 3 | 5 |
fitness-gym | 4 | 3 | 3 | 3 |
golf | 0 | 5 | 4 | 2 |
hunting-fishing | 2 | 3 | 3 | 5 |
Best for: Outdoor retailers, sporting goods stores, athletic equipment shops.
General Retail Template
A conservative template for stores with mild or no seasonality. Priorities are flatter across seasons with modest adjustments for holiday shopping and back-to-school periods. Best used as a starting point for customization.
| Category Group | Winter | Spring | Summer | Fall |
|---|---|---|---|---|
seasonal-high | 5 | 2 | 2 | 4 |
seasonal-low | 2 | 3 | 4 | 2 |
year-round-core | 3 | 3 | 3 | 3 |
gift-items | 5 | 2 | 2 | 4 |
clearance | 1 | 1 | 1 | 1 |
new-arrivals | 4 | 4 | 4 | 4 |
premium | 3 | 3 | 3 | 4 |
basics | 2 | 2 | 2 | 2 |
Best for: Home goods, electronics, general merchandise, or any store with light seasonality.
Concurrency and Distributed Safety
Why Concurrency Matters
In a multi-tenant SaaS environment, the seasonal transition job processes all active tenants sequentially. However, other operations (webhook handlers, manual rule changes, bulk edits) may also trigger priority recalculations concurrently. Without proper locking, two recalculation jobs for the same tenant could interleave and produce inconsistent scores.
Locking Strategy
AdPriority uses PostgreSQL advisory locks to ensure that only one recalculation runs per tenant at any given time. Advisory locks are lightweight, do not conflict with normal row-level locks, and are automatically released when the transaction or session ends.
// src/services/priority/lock.ts
import { PrismaClient } from '@prisma/client';
/**
* Acquire a PostgreSQL advisory lock for a tenant's recalculation.
* Uses the tenant's numeric hash as the lock key.
* Returns true if the lock was acquired, false if another process holds it.
*/
export async function acquireRecalculationLock(
prisma: PrismaClient,
tenantId: string
): Promise<boolean> {
const lockKey = hashTenantId(tenantId);
const result = await prisma.$queryRaw<{ acquired: boolean }[]>`
SELECT pg_try_advisory_lock(${lockKey}) as acquired
`;
return result[0]?.acquired ?? false;
}
/**
* Release the advisory lock for a tenant.
*/
export async function releaseRecalculationLock(
prisma: PrismaClient,
tenantId: string
): Promise<void> {
const lockKey = hashTenantId(tenantId);
await prisma.$queryRaw`SELECT pg_advisory_unlock(${lockKey})`;
}
function hashTenantId(tenantId: string): number {
let hash = 0;
for (let i = 0; i < tenantId.length; i++) {
hash = ((hash << 5) - hash + tenantId.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
Using the Lock in Recalculation
// Wrap any recalculation call with the advisory lock
const locked = await acquireRecalculationLock(prisma, tenantId);
if (!locked) {
console.log(`[seasonal] Skipping ${tenantId}: recalculation already in progress`);
return;
}
try {
const result = await recalculateAllPriorities(prisma, tenantId, config, opts);
return result;
} finally {
await releaseRecalculationLock(prisma, tenantId);
}
Redis-Based Job Deduplication
Bull queues provide built-in job deduplication via the jobId option. The seasonal transition job uses the tenant ID and date as the job ID to prevent duplicate recalculations if the scheduler fires twice.
// Enqueue a recalculation job with deduplication
await recalculationQueue.add(
'recalculate',
{ tenantId, season: seasonInfo.currentSeason },
{ jobId: `seasonal-${tenantId}-${today.toISOString().slice(0, 10)}` }
);