Files
Cibello-app/packages/recommendation-engine/src/season.ts
T
Sven (AAMOS AI) 19af7e0720
CI / Typecheck, test & build (push) Failing after 42s
feat(season): easter-offset + nth-weekday datumregler för marknads-högtider (MIKRO A)
2026-08-14 21:46:17 +07:00

137 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Season } from "@app/shared-types";
/**
* All kalenderaritmetik i denna modul sker i UTC (Date.UTC/getUTC*).
* Motorn ska ge identiskt resultat oavsett serverns tidszon och UTC har
* ingen sommartid, så dygnsmatte med millisekunder är alltid exakt.
*/
/** Meteorologisk årstid för norra halvklotet (SE-marknad). */
export function seasonForDate(date: Date): Season {
const m = date.getUTCMonth() + 1;
if (m >= 3 && m <= 5) return "spring";
if (m >= 6 && m <= 8) return "summer";
if (m >= 9 && m <= 11) return "autumn";
return "winter";
}
/** Midsommarafton: fredagen mellan 19 och 25 juni. */
export function midsummerEve(year: number): Date {
for (let day = 19; day <= 25; day++) {
const d = new Date(Date.UTC(year, 5, day));
if (d.getUTCDay() === 5) return d;
}
return new Date(Date.UTC(year, 5, 24));
}
/** Påskdagen enligt anonym gregoriansk algoritm. */
export function easterSunday(year: number): Date {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return new Date(Date.UTC(year, month - 1, day));
}
/** Första advent: fjärde söndagen före juldagen. */
export function firstAdvent(year: number): Date {
const christmas = new Date(Date.UTC(year, 11, 25));
const dayOfWeek = christmas.getUTCDay();
return new Date(Date.UTC(year, 11, 25 - (dayOfWeek === 0 ? 7 : dayOfWeek) - 21));
}
export interface SeasonEventRuleInput {
dateRule:
| { kind: "fixed"; monthDay: string }
| { kind: "range"; startMonthDay: string; endMonthDay: string }
| { kind: "computed"; algorithm: "midsummer" | "easter" | "advent" | "custom" }
| { kind: "easter-offset"; days: number }
| { kind: "nth-weekday"; month: number; weekday: number; n: number | "last" };
leadDays: number;
}
/** Är eventet aktivt (inom leadDays före, till och med eventdagen/perioden)? */
export function isEventActive(rule: SeasonEventRuleInput, today: Date): boolean {
const year = today.getUTCFullYear();
const t = strip(today).getTime();
const DAY = 86_400_000;
const activeAround = (target: Date, tailDays = 1): boolean => {
const start = strip(target).getTime() - rule.leadDays * DAY;
const end = strip(target).getTime() + tailDays * DAY;
return t >= start && t <= end;
};
switch (rule.dateRule.kind) {
case "fixed": {
const [mm, dd] = rule.dateRule.monthDay.split("-").map(Number);
return activeAround(new Date(Date.UTC(year, (mm ?? 1) - 1, dd ?? 1)));
}
case "range": {
const [sm, sd] = rule.dateRule.startMonthDay.split("-").map(Number);
const [em, ed] = rule.dateRule.endMonthDay.split("-").map(Number);
const start = Date.UTC(year, (sm ?? 1) - 1, sd ?? 1) - rule.leadDays * DAY;
let end = Date.UTC(year, (em ?? 1) - 1, ed ?? 1);
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":
return activeAround(midsummerEve(year), 2);
case "easter":
return activeAround(easterSunday(year), 1);
case "advent":
return activeAround(firstAdvent(year), 28);
case "custom":
return false;
}
}
}
}
/** UTC-dygnets start oberoende av serverns tidszon. */
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 112, 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));
}