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
| 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 | 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 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
| 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 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-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 the core value proposition of the Growth tier and represents the difference between “nice to have” and “cannot live without” for seasonal retailers.