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 @@ + + First day of autumn + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + Thingport's birthday + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + Christmas + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + International Coffee Day + + + + + + + + + + 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 @@ + + Diwali + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + Easter + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + Halloween + + + + + + + + + + + + + + + + + + + + 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 @@ + + Hanukkah + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + Holi + + + + + + + + + + + + + + + + + + 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 @@ + + Joninės + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + Lunar New Year + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + Día de los Muertos + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + New Year + + + + + + + + + + + + + + + + + + + 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 @@ + + International Programmers' Day + + 1 + 0 + 1 + 1 + 0 + 0 + 1 + 0 + 1 + 1 + 0 + 1 + </> + + 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 @@ + + Ramadan and Eid al-Fitr + + + + + + + + + + + + + + + + + + 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 @@ + + Šaltibarščių diena + + + + + + + + + + + + + + + + + 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 @@ + + First day of spring + + + + + + + + + + + + + + + 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 @@ + + St. Patrick's Day + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + First day of summer + + + + + + + + + + + + + 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 @@ + + Valentine's Day + + + + + + + + + + + + + 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 @@ + + First day of winter + + + + + + + + + + + + + + + + + + + + + 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 @@ + + International Women's Day + + + + + + + + + + + + + + + + + + + + + + + + + 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; +}