137 lines
4.8 KiB
TypeScript
137 lines
4.8 KiB
TypeScript
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 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));
|
||
}
|