diff --git a/frontend/src/assets/logos/seasonal/autumn.svg b/frontend/src/assets/logos/seasonal/autumn.svg
new file mode 100644
index 0000000..aa99a40
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/autumn.svg
@@ -0,0 +1,33 @@
+
diff --git a/frontend/src/assets/logos/seasonal/birthday.svg b/frontend/src/assets/logos/seasonal/birthday.svg
new file mode 100644
index 0000000..ebc3dbb
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/birthday.svg
@@ -0,0 +1,38 @@
+
diff --git a/frontend/src/assets/logos/seasonal/christmas.svg b/frontend/src/assets/logos/seasonal/christmas.svg
new file mode 100644
index 0000000..d462653
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/christmas.svg
@@ -0,0 +1,71 @@
+
diff --git a/frontend/src/assets/logos/seasonal/coffee.svg b/frontend/src/assets/logos/seasonal/coffee.svg
new file mode 100644
index 0000000..5c9ff38
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/coffee.svg
@@ -0,0 +1,17 @@
+
diff --git a/frontend/src/assets/logos/seasonal/diwali.svg b/frontend/src/assets/logos/seasonal/diwali.svg
new file mode 100644
index 0000000..c4cd843
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/diwali.svg
@@ -0,0 +1,32 @@
+
diff --git a/frontend/src/assets/logos/seasonal/easter.svg b/frontend/src/assets/logos/seasonal/easter.svg
new file mode 100644
index 0000000..20d470b
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/easter.svg
@@ -0,0 +1,37 @@
+
diff --git a/frontend/src/assets/logos/seasonal/halloween.svg b/frontend/src/assets/logos/seasonal/halloween.svg
new file mode 100644
index 0000000..d2e697c
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/halloween.svg
@@ -0,0 +1,33 @@
+
diff --git a/frontend/src/assets/logos/seasonal/hanukkah.svg b/frontend/src/assets/logos/seasonal/hanukkah.svg
new file mode 100644
index 0000000..b8a80a6
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/hanukkah.svg
@@ -0,0 +1,34 @@
+
diff --git a/frontend/src/assets/logos/seasonal/holi.svg b/frontend/src/assets/logos/seasonal/holi.svg
new file mode 100644
index 0000000..f543f99
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/holi.svg
@@ -0,0 +1,24 @@
+
diff --git a/frontend/src/assets/logos/seasonal/jonines.svg b/frontend/src/assets/logos/seasonal/jonines.svg
new file mode 100644
index 0000000..de98acf
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/jonines.svg
@@ -0,0 +1,38 @@
+
diff --git a/frontend/src/assets/logos/seasonal/lunarnewyear.svg b/frontend/src/assets/logos/seasonal/lunarnewyear.svg
new file mode 100644
index 0000000..b594d51
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/lunarnewyear.svg
@@ -0,0 +1,46 @@
+
diff --git a/frontend/src/assets/logos/seasonal/muertos.svg b/frontend/src/assets/logos/seasonal/muertos.svg
new file mode 100644
index 0000000..aacaea3
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/muertos.svg
@@ -0,0 +1,35 @@
+
diff --git a/frontend/src/assets/logos/seasonal/newyear.svg b/frontend/src/assets/logos/seasonal/newyear.svg
new file mode 100644
index 0000000..9a2c935
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/newyear.svg
@@ -0,0 +1,28 @@
+
diff --git a/frontend/src/assets/logos/seasonal/programmersday.svg b/frontend/src/assets/logos/seasonal/programmersday.svg
new file mode 100644
index 0000000..0bc2910
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/programmersday.svg
@@ -0,0 +1,25 @@
+
diff --git a/frontend/src/assets/logos/seasonal/ramadan.svg b/frontend/src/assets/logos/seasonal/ramadan.svg
new file mode 100644
index 0000000..5a96d75
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/ramadan.svg
@@ -0,0 +1,26 @@
+
diff --git a/frontend/src/assets/logos/seasonal/saltibarsciai.svg b/frontend/src/assets/logos/seasonal/saltibarsciai.svg
new file mode 100644
index 0000000..f56ac52
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/saltibarsciai.svg
@@ -0,0 +1,28 @@
+
diff --git a/frontend/src/assets/logos/seasonal/spring.svg b/frontend/src/assets/logos/seasonal/spring.svg
new file mode 100644
index 0000000..003a9f5
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/spring.svg
@@ -0,0 +1,27 @@
+
diff --git a/frontend/src/assets/logos/seasonal/stpatricks.svg b/frontend/src/assets/logos/seasonal/stpatricks.svg
new file mode 100644
index 0000000..0c3f9f9
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/stpatricks.svg
@@ -0,0 +1,33 @@
+
diff --git a/frontend/src/assets/logos/seasonal/summer.svg b/frontend/src/assets/logos/seasonal/summer.svg
new file mode 100644
index 0000000..438d2e2
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/summer.svg
@@ -0,0 +1,21 @@
+
diff --git a/frontend/src/assets/logos/seasonal/valentines.svg b/frontend/src/assets/logos/seasonal/valentines.svg
new file mode 100644
index 0000000..9ebc815
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/valentines.svg
@@ -0,0 +1,21 @@
+
diff --git a/frontend/src/assets/logos/seasonal/winter.svg b/frontend/src/assets/logos/seasonal/winter.svg
new file mode 100644
index 0000000..d681c3c
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/winter.svg
@@ -0,0 +1,31 @@
+
diff --git a/frontend/src/assets/logos/seasonal/womensday.svg b/frontend/src/assets/logos/seasonal/womensday.svg
new file mode 100644
index 0000000..a0db008
--- /dev/null
+++ b/frontend/src/assets/logos/seasonal/womensday.svg
@@ -0,0 +1,36 @@
+
diff --git a/frontend/src/components/Layout/Sidebar/index.tsx b/frontend/src/components/Layout/Sidebar/index.tsx
index d8e8094..c16ac00 100644
--- a/frontend/src/components/Layout/Sidebar/index.tsx
+++ b/frontend/src/components/Layout/Sidebar/index.tsx
@@ -278,7 +278,7 @@ export default function Sidebar({ isAdmin, onSelectCategory, bookmarksVersion }:
aria-label={t("sidebar.dashboard")}
sx={{ display: "flex", alignItems: "center", lineHeight: 0 }}
>
-
+
)}
diff --git a/frontend/src/components/Wordmark.tsx b/frontend/src/components/Wordmark.tsx
index e1a4042..0da86b3 100644
--- a/frontend/src/components/Wordmark.tsx
+++ b/frontend/src/components/Wordmark.tsx
@@ -1,10 +1,37 @@
+import { useState } from "react";
+import { keyframes } from "@emotion/react";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import lockupColor from "../assets/logos/thingport-lockup-compact-color.svg";
import lockupOnDark from "../assets/logos/thingport-lockup-compact-on-dark.svg";
+import autumn from "../assets/logos/seasonal/autumn.svg";
+import birthday from "../assets/logos/seasonal/birthday.svg";
+import christmas from "../assets/logos/seasonal/christmas.svg";
+import coffee from "../assets/logos/seasonal/coffee.svg";
+import diwali from "../assets/logos/seasonal/diwali.svg";
+import easter from "../assets/logos/seasonal/easter.svg";
+import halloween from "../assets/logos/seasonal/halloween.svg";
+import hanukkah from "../assets/logos/seasonal/hanukkah.svg";
+import holi from "../assets/logos/seasonal/holi.svg";
+import jonines from "../assets/logos/seasonal/jonines.svg";
+import lunarNewYear from "../assets/logos/seasonal/lunarnewyear.svg";
+import muertos from "../assets/logos/seasonal/muertos.svg";
+import newYear from "../assets/logos/seasonal/newyear.svg";
+import programmersDay from "../assets/logos/seasonal/programmersday.svg";
+import ramadan from "../assets/logos/seasonal/ramadan.svg";
+import saltibarsciai from "../assets/logos/seasonal/saltibarsciai.svg";
+import spring from "../assets/logos/seasonal/spring.svg";
+import stPatricks from "../assets/logos/seasonal/stpatricks.svg";
+import summer from "../assets/logos/seasonal/summer.svg";
+import valentines from "../assets/logos/seasonal/valentines.svg";
+import winter from "../assets/logos/seasonal/winter.svg";
+import womensDay from "../assets/logos/seasonal/womensday.svg";
+import { type Season, seasonFor } from "../utils/season";
type Props = {
size?: "lg" | "md" | "sm";
+ /** Dresses the logo up for the season it is, if any. */
+ seasonal?: boolean;
};
const HEIGHTS: Record, string> = {
@@ -13,10 +40,84 @@ const HEIGHTS: Record, string> = {
sm: "1.125rem",
};
-export default function Wordmark({ size = "md" }: Props) {
+// April Fools' turns the logo itself, so it has none.
+const OVERLAYS: Record, string> = {
+ newYear,
+ lunarNewYear,
+ valentines,
+ holi,
+ womensDay,
+ stPatricks,
+ spring,
+ ramadan,
+ easter,
+ saltibarsciai,
+ summer,
+ jonines,
+ birthday,
+ programmersDay,
+ autumn,
+ coffee,
+ diwali,
+ halloween,
+ muertos,
+ hanukkah,
+ winter,
+ christmas,
+};
+
+// Upside down all day, with a wobble now and then as if it noticed.
+const upsideDown = keyframes`
+ 0%, 70%, 100% { transform: rotate(180deg); }
+ 76% { transform: rotate(168deg); }
+ 82% { transform: rotate(191deg); }
+ 88% { transform: rotate(176deg); }
+ 94% { transform: rotate(183deg); }
+`;
+
+export default function Wordmark({ size = "md", seasonal = false }: Props) {
const theme = useTheme();
+ const [season] = useState(() => (seasonal ? seasonFor(new Date()) : null));
const src = theme.palette.mode === "dark" ? lockupOnDark : lockupColor;
+ const height = HEIGHTS[size];
+ const overlay = season && season !== "aprilFools" ? OVERLAYS[season] : undefined;
+ const logo = (
+
+ );
+ if (!overlay) return logo;
return (
-
+
+ {logo}
+ {/* The overlay's viewBox (-30 -45 530 175) is the logo's (0 0 411 100) with room around it, so it lines up
+ at any size and may spill a little past the logo. */}
+
+
);
}
diff --git a/frontend/src/utils/season.test.ts b/frontend/src/utils/season.test.ts
new file mode 100644
index 0000000..e500551
--- /dev/null
+++ b/frontend/src/utils/season.test.ts
@@ -0,0 +1,115 @@
+import { describe, expect, it } from "vitest";
+import { easterSunday, equinox, seasonFor } from "./season";
+
+const on = (year: number, month: number, day: number) => seasonFor(new Date(year, month - 1, day, 12));
+const minutesOff = (date: Date, iso: string) => Math.abs(date.getTime() - Date.parse(iso)) / 60_000;
+const dayAfter = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1, 12);
+
+describe("seasonFor", () => {
+ it("knows Easter Sunday in years it moves around", () => {
+ expect(easterSunday(2025).toDateString()).toBe(new Date(2025, 3, 20).toDateString());
+ expect(easterSunday(2026).toDateString()).toBe(new Date(2026, 3, 5).toDateString());
+ expect(easterSunday(2027).toDateString()).toBe(new Date(2027, 2, 28).toDateString());
+ });
+
+ it("is Easter from Good Friday to Easter Monday", () => {
+ expect(on(2027, 3, 25)).toBeNull();
+ expect(on(2027, 3, 26)).toBe("easter");
+ expect(on(2027, 3, 29)).toBe("easter");
+ expect(on(2027, 3, 30)).toBeNull();
+ });
+
+ it("is Christmas from 18 December to the year's end, and New Year for its first two days", () => {
+ expect(on(2026, 12, 17)).toBeNull();
+ expect(on(2026, 12, 18)).toBe("christmas");
+ expect(on(2026, 12, 31)).toBe("christmas");
+ expect(on(2027, 1, 1)).toBe("newYear");
+ expect(on(2027, 1, 2)).toBe("newYear");
+ expect(on(2027, 1, 3)).toBeNull();
+ });
+
+ it("knows the one-day ones", () => {
+ expect(on(2027, 2, 14)).toBe("valentines");
+ expect(on(2027, 3, 8)).toBe("womensDay");
+ expect(on(2027, 3, 17)).toBe("stPatricks");
+ expect(on(2027, 4, 1)).toBe("aprilFools");
+ expect(on(2027, 9, 7)).toBe("birthday");
+ expect(on(2027, 10, 1)).toBe("coffee");
+ expect(on(2027, 6, 10)).toBe("saltibarsciai");
+ });
+
+ it("puts Programmers' Day on the 256th day, a day earlier in a leap year", () => {
+ expect(on(2027, 9, 13)).toBe("programmersDay");
+ expect(on(2027, 9, 12)).toBeNull();
+ expect(on(2028, 9, 12)).toBe("programmersDay");
+ expect(on(2028, 9, 13)).toBeNull();
+ });
+
+ it("is Halloween for its last three days of October, then the Day of the Dead", () => {
+ expect(on(2026, 10, 28)).toBeNull();
+ expect(on(2026, 10, 29)).toBe("halloween");
+ expect(on(2026, 10, 31)).toBe("halloween");
+ expect(on(2026, 11, 1)).toBe("muertos");
+ expect(on(2026, 11, 2)).toBe("muertos");
+ expect(on(2026, 11, 3)).toBeNull();
+ });
+
+ it("is Joninės on its eve and its day", () => {
+ expect(on(2027, 6, 23)).toBe("jonines");
+ expect(on(2027, 6, 24)).toBe("jonines");
+ expect(on(2027, 6, 25)).toBeNull();
+ });
+
+ it("finds the lunar ones in their tables", () => {
+ expect(on(2027, 2, 6)).toBe("lunarNewYear");
+ expect(on(2027, 2, 7)).toBe("lunarNewYear");
+ expect(on(2027, 3, 21)).toBe("holi");
+ expect(on(2027, 3, 22)).toBe("holi");
+ expect(on(2026, 11, 8)).toBe("diwali");
+ expect(on(2026, 12, 4)).toBe("hanukkah");
+ expect(on(2026, 12, 11)).toBe("hanukkah");
+ expect(on(2026, 12, 12)).toBeNull();
+ });
+
+ it("marks Ramadan's first day and Eid on either day it may fall", () => {
+ expect(on(2027, 2, 8)).toBe("ramadan");
+ expect(on(2027, 2, 9)).toBeNull();
+ expect(on(2027, 3, 9)).toBe("ramadan");
+ expect(on(2027, 3, 10)).toBe("ramadan");
+ expect(on(2027, 3, 11)).toBeNull();
+ });
+
+ it("puts the equinoxes and solstices within minutes of when they happen", () => {
+ expect(minutesOff(equinox(2026, "march"), "2026-03-20T14:46Z")).toBeLessThan(15);
+ expect(minutesOff(equinox(2026, "june"), "2026-06-21T08:24Z")).toBeLessThan(15);
+ expect(minutesOff(equinox(2026, "september"), "2026-09-23T00:05Z")).toBeLessThan(15);
+ expect(minutesOff(equinox(2026, "december"), "2026-12-21T20:50Z")).toBeLessThan(15);
+ });
+
+ it("shows each season's first day on the user's own calendar day of it", () => {
+ for (const [which, season] of [
+ ["june", "summer"],
+ ["september", "autumn"],
+ ] as const) {
+ const moment = equinox(2027, which);
+ expect(seasonFor(moment)).toBe(season);
+ // Not null: east of UTC, the June solstice's day after can already be Joninės.
+ expect(seasonFor(dayAfter(moment))).not.toBe(season);
+ }
+ });
+
+ it("lets a holiday win over a longer or minor one on the same day", () => {
+ // Christmas Eve over the first day of Hanukkah 2027, then Hanukkah over the days after Christmas.
+ expect(on(2027, 12, 24)).toBe("christmas");
+ expect(on(2027, 12, 28)).toBe("hanukkah");
+ // Diwali over Halloween; Eid over the March equinox, both on 20 March 2026.
+ expect(on(2027, 10, 29)).toBe("diwali");
+ expect(on(2026, 3, 20)).toBe("ramadan");
+ // The winter solstice through the days before Christmas.
+ expect(seasonFor(equinox(2026, "december"))).toBe("winter");
+ });
+
+ it("is an ordinary day otherwise", () => {
+ expect(on(2026, 10, 14)).toBeNull();
+ });
+});
diff --git a/frontend/src/utils/season.ts b/frontend/src/utils/season.ts
new file mode 100644
index 0000000..8f5be77
--- /dev/null
+++ b/frontend/src/utils/season.ts
@@ -0,0 +1,203 @@
+export type Season =
+ | "newYear"
+ | "lunarNewYear"
+ | "valentines"
+ | "holi"
+ | "womensDay"
+ | "stPatricks"
+ | "spring"
+ | "ramadan"
+ | "aprilFools"
+ | "easter"
+ | "saltibarsciai"
+ | "summer"
+ | "jonines"
+ | "birthday"
+ | "programmersDay"
+ | "autumn"
+ | "coffee"
+ | "diwali"
+ | "halloween"
+ | "muertos"
+ | "hanukkah"
+ | "winter"
+ | "christmas";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+// Lunar calendars have no simple formula, so these are looked up; past the last year, that season just doesn't show.
+// Ramadan begins by moon sighting, so its days can be a day off where the user is.
+const LUNAR_NEW_YEAR = [
+ "2026-02-17",
+ "2027-02-06",
+ "2028-01-26",
+ "2029-02-13",
+ "2030-02-03",
+ "2031-01-23",
+ "2032-02-11",
+ "2033-01-31",
+ "2034-02-19",
+ "2035-02-08",
+];
+/** Holika Dahan, the bonfire night; Rangwali Holi, the colour day, is the next. */
+const HOLI = ["2026-03-03", "2027-03-21", "2028-03-10", "2029-02-28", "2030-03-19", "2031-03-08"];
+const RAMADAN_START = [
+ "2026-02-18",
+ "2027-02-08",
+ "2028-01-28",
+ "2029-01-16",
+ "2030-01-05",
+ "2030-12-26",
+ "2031-12-15",
+ "2032-12-04",
+ "2033-11-23",
+ "2034-11-12",
+ "2035-11-01",
+];
+/** Lakshmi Puja, the main day. */
+const DIWALI = ["2026-11-08", "2027-10-29", "2028-10-17", "2029-11-05", "2030-10-26"];
+/** The evening the first candle is lit. */
+const HANUKKAH = ["2026-12-04", "2027-12-24", "2028-12-12", "2029-12-01", "2030-12-20"];
+
+/** Western (Gregorian) Easter Sunday, by the anonymous Gregorian algorithm. */
+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(year, month - 1, day);
+}
+
+// Meeus, Astronomical Algorithms, ch. 27: mean equinox/solstice for 2000-3000, then its periodic terms.
+const MEAN = {
+ march: [2451623.80984, 365242.37404, 0.05169, -0.00411, -0.00057],
+ june: [2451716.56767, 365241.62603, 0.00325, 0.00888, -0.0003],
+ september: [2451810.21715, 365242.01767, -0.11575, 0.00337, 0.00078],
+ december: [2451900.05952, 365242.74049, -0.06223, -0.00823, 0.00032],
+};
+const PERIODIC = [
+ [485, 324.96, 1934.136],
+ [203, 337.23, 32964.467],
+ [199, 342.08, 20.186],
+ [182, 27.85, 445267.112],
+ [156, 73.14, 45036.886],
+ [136, 171.52, 22518.443],
+ [77, 222.54, 65928.934],
+ [74, 296.72, 3034.906],
+ [70, 243.58, 9037.513],
+ [58, 119.81, 33718.147],
+ [52, 297.17, 150.678],
+ [50, 21.02, 2281.226],
+ [45, 247.54, 29929.562],
+ [44, 325.15, 31555.956],
+ [29, 60.93, 4443.417],
+ [18, 155.12, 67555.328],
+ [17, 288.79, 4562.452],
+ [16, 198.04, 62894.029],
+ [14, 199.76, 31436.921],
+ [12, 95.39, 14577.848],
+ [12, 287.11, 31931.756],
+ [12, 320.81, 34777.259],
+ [9, 227.73, 1222.114],
+ [8, 15.45, 16859.074],
+];
+const RAD = Math.PI / 180;
+
+/** The moment of a year's equinox or solstice, to within a few minutes. */
+export function equinox(year: number, which: keyof typeof MEAN): Date {
+ const y = (year - 2000) / 1000;
+ const jde0 = MEAN[which].reduce((sum, coefficient, power) => sum + coefficient * y ** power, 0);
+ const t = (jde0 - 2451545) / 36525;
+ const w = (35999.373 * t - 2.47) * RAD;
+ const lambda = 1 + 0.0334 * Math.cos(w) + 0.0007 * Math.cos(2 * w);
+ const s = PERIODIC.reduce((sum, [a, b, c]) => sum + a * Math.cos((b + c * t) * RAD), 0);
+ const jde = jde0 + (0.00001 * s) / lambda;
+ // Julian day to Unix time; the minute or so between TT and UTC doesn't matter here.
+ return new Date((jde - 2440587.5) * DAY_MS);
+}
+
+/** `minor`: gives way to any holiday, as the equinoxes do. */
+type Window = { season: Season; start: Date; days: number; minor?: boolean };
+
+const atMidnight = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());
+
+function fromTable(season: Season, dates: string[], offset: number, days: number): Window[] {
+ return dates.map((iso) => {
+ const [year, month, day] = iso.split("-").map(Number);
+ return { season, start: new Date(year, month - 1, day + offset), days };
+ });
+}
+
+function windowsAround(year: number): Window[] {
+ const fixed = (season: Season, month: number, day: number, days = 1): Window => ({
+ season,
+ start: new Date(year, month - 1, day),
+ days,
+ });
+ const easter = easterSunday(year);
+ return [
+ fixed("newYear", 1, 1, 2),
+ fixed("valentines", 2, 14),
+ fixed("womensDay", 3, 8),
+ fixed("stPatricks", 3, 17),
+ fixed("aprilFools", 4, 1),
+ // Good Friday to Easter Monday.
+ { season: "easter", start: new Date(year, easter.getMonth(), easter.getDate() - 2), days: 4 },
+ // Vilnius's day of the cold pink soup; its Pink Soup Fest moves around early June.
+ fixed("saltibarsciai", 6, 10),
+ // Midsummer: the eve, when the fern flower is looked for, and the day.
+ fixed("jonines", 6, 23, 2),
+ // The day of Thingport's first commit, 7 September 2026.
+ fixed("birthday", 9, 7),
+ // The 256th day (2^8): 13 September, or the 12th in a leap year.
+ { season: "programmersDay", start: new Date(year, 0, 256), days: 1 },
+ fixed("coffee", 10, 1),
+ fixed("halloween", 10, 29, 3),
+ fixed("muertos", 11, 1, 2),
+ // Christmas Eve to Boxing Day always; the days around them give way, even to the winter solstice.
+ fixed("christmas", 12, 24, 3),
+ { ...fixed("christmas", 12, 18, 14), minor: true },
+ // On the user's own calendar day of each, wherever they are.
+ { season: "spring", start: atMidnight(equinox(year, "march")), days: 1, minor: true },
+ { season: "summer", start: atMidnight(equinox(year, "june")), days: 1, minor: true },
+ { season: "autumn", start: atMidnight(equinox(year, "september")), days: 1, minor: true },
+ { season: "winter", start: atMidnight(equinox(year, "december")), days: 1, minor: true },
+ ];
+}
+
+const rank = (window: Window) => (window.minor ? 1000 : 0) + window.days;
+
+const LOOKED_UP: Window[] = [
+ ...fromTable("lunarNewYear", LUNAR_NEW_YEAR, 0, 2),
+ ...fromTable("holi", HOLI, 0, 2),
+ // Ramadan's first day, then Eid al-Fitr on both days it may fall, after 29 or 30 days of fasting.
+ ...fromTable("ramadan", RAMADAN_START, 0, 1),
+ ...fromTable("ramadan", RAMADAN_START, 29, 2),
+ ...fromTable("diwali", DIWALI, 0, 1),
+ ...fromTable("hanukkah", HANUKKAH, 0, 8),
+];
+
+/**
+ * The season `date` falls in, by the user's own calendar day; null on an ordinary day. Where two overlap, a holiday
+ * beats a minor one, then the shorter wins, so e.g. Diwali shows through Halloween's three days.
+ */
+export function seasonFor(date: Date): Season | null {
+ const today = atMidnight(date);
+ let best: Window | null = null;
+ for (const window of [...windowsAround(today.getFullYear()), ...LOOKED_UP]) {
+ // Round: a day that crosses a daylight saving change is an hour short or long.
+ const into = Math.round((today.getTime() - window.start.getTime()) / DAY_MS);
+ if (into >= 0 && into < window.days && (!best || rank(window) < rank(best))) best = window;
+ }
+ return best?.season ?? null;
+}