From 19af7e07206154ce9007d46843161e6f6fadb85d Mon Sep 17 00:00:00 2001 From: "Sven (AAMOS AI)" Date: Fri, 14 Aug 2026 21:46:17 +0700 Subject: [PATCH] =?UTF-8?q?feat(season):=20easter-offset=20+=20nth-weekday?= =?UTF-8?q?=20datumregler=20f=C3=B6r=20marknads-h=C3=B6gtider=20(MIKRO=20A?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/database/src/schema/seasons.ts | 4 ++- packages/recommendation-engine/src/season.ts | 34 +++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/database/src/schema/seasons.ts b/packages/database/src/schema/seasons.ts index 57531f1..630151a 100644 --- a/packages/database/src/schema/seasons.ts +++ b/packages/database/src/schema/seasons.ts @@ -4,7 +4,9 @@ import { createdAt, updatedAt } from "./_shared.js"; type DateRule = | { kind: "fixed"; monthDay: string } | { kind: "range"; startMonthDay: string; endMonthDay: string } - | { kind: "computed"; algorithm: "midsummer" | "easter" | "advent" | "custom" }; + | { kind: "computed"; algorithm: "midsummer" | "easter" | "advent" | "custom" } + | { kind: "easter-offset"; days: number } + | { kind: "nth-weekday"; month: number; weekday: number; n: number | "last" }; /** * Datadriven Season & Events Engine (spec §28): midsommar, jul, påsk, kräftskiva, diff --git a/packages/recommendation-engine/src/season.ts b/packages/recommendation-engine/src/season.ts index 3f1e0f2..740646c 100644 --- a/packages/recommendation-engine/src/season.ts +++ b/packages/recommendation-engine/src/season.ts @@ -54,7 +54,9 @@ export interface SeasonEventRuleInput { dateRule: | { kind: "fixed"; monthDay: string } | { kind: "range"; startMonthDay: string; endMonthDay: string } - | { kind: "computed"; algorithm: "midsummer" | "easter" | "advent" | "custom" }; + | { kind: "computed"; algorithm: "midsummer" | "easter" | "advent" | "custom" } + | { kind: "easter-offset"; days: number } + | { kind: "nth-weekday"; month: number; weekday: number; n: number | "last" }; leadDays: number; } @@ -83,6 +85,19 @@ export function isEventActive(rule: SeasonEventRuleInput, today: Date): boolean if (end < start) end = Date.UTC(year + 1, (em ?? 1) - 1, ed ?? 1); return t >= start && t <= end; } + case "easter-offset": { + const target = new Date(easterSunday(year).getTime() + rule.dateRule.days * DAY); + return activeAround(target, 1); + } + case "nth-weekday": { + const target = nthWeekdayOfMonth( + year, + rule.dateRule.month, + rule.dateRule.weekday, + rule.dateRule.n, + ); + return activeAround(target, 1); + } case "computed": { switch (rule.dateRule.algorithm) { case "midsummer": @@ -102,3 +117,20 @@ export function isEventActive(rule: SeasonEventRuleInput, today: Date): boolean function strip(d: Date): Date { return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); } + +/** N:te (eller sista) veckodagen i en månad. month 1–12, weekday 0=sön..6=lör. */ +function nthWeekdayOfMonth( + year: number, + month: number, + weekday: number, + n: number | "last", +): Date { + if (n === "last") { + const last = new Date(Date.UTC(year, month, 0)); // sista dagen i månaden + const back = (last.getUTCDay() - weekday + 7) % 7; + return new Date(Date.UTC(year, month - 1, last.getUTCDate() - back)); + } + const first = new Date(Date.UTC(year, month - 1, 1)); + const fwd = (weekday - first.getUTCDay() + 7) % 7; + return new Date(Date.UTC(year, month - 1, 1 + fwd + (n - 1) * 7)); +}