diff --git a/src/app/components/seasonal/SeasonalEffect.tsx b/src/app/components/seasonal/SeasonalEffect.tsx index b56569eab..82b4f2e34 100644 --- a/src/app/components/seasonal/SeasonalEffect.tsx +++ b/src/app/components/seasonal/SeasonalEffect.tsx @@ -1,10 +1,10 @@ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useAtomValue } from 'jotai'; import { settingsAtom } from '../../state/settings'; import { useReducedMotion } from '../../hooks/useReducedMotion'; import { zIndices } from '../../styles/zIndex'; import { SeasonTheme } from './types'; -import { getActiveSeason } from './seasonSchedule'; +import { resolveSeasonTheme } from './seasonSchedule'; import { HalloweenOverlay } from './themes/Halloween'; import { ChristmasOverlay } from './themes/Christmas'; import { NewYearOverlay } from './themes/NewYear'; @@ -96,13 +96,25 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) { export function SeasonalEffect() { const settings = useAtomValue(settingsAtom); const reduced = useReducedMotion(); + const override = settings.seasonalThemeOverride ?? 'auto'; - const theme = useMemo(() => { - const override = settings.seasonalThemeOverride ?? 'auto'; - if (override === 'off') return null; - if (override === 'auto') return getActiveSeason(new Date()); - return override as SeasonTheme; - }, [settings.seasonalThemeOverride]); + // In auto mode, re-evaluate hourly so a long-lived session crosses a + // season/holiday-window boundary (e.g. into a new day) without a reload — + // otherwise the active season is frozen at the value it had on mount. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (override !== 'auto') return undefined; + // Refresh on entering auto too: `now` may be a stale mount-time value if we + // were previously in a pinned/off mode (the interval only runs while auto). + setNow(Date.now()); + const id = window.setInterval(() => setNow(Date.now()), 60 * 60 * 1000); + return () => window.clearInterval(id); + }, [override]); + + const theme = useMemo( + () => resolveSeasonTheme(override, now), + [override, now], + ); if (!theme) return null; // Suppress seasonal overlay when a chat background is active — both running simultaneously diff --git a/src/app/components/seasonal/seasonSchedule.test.ts b/src/app/components/seasonal/seasonSchedule.test.ts index b67200444..76cffc3ff 100644 --- a/src/app/components/seasonal/seasonSchedule.test.ts +++ b/src/app/components/seasonal/seasonSchedule.test.ts @@ -1,7 +1,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { getActiveSeason, SEASON_SCHEDULE, SEASON_DATE_RANGES } from './seasonSchedule'; +import { + getActiveSeason, + resolveSeasonTheme, + SEASON_SCHEDULE, + SEASON_DATE_RANGES, +} from './seasonSchedule'; import { SeasonTheme } from './types'; // Date(year, monthIndex0, day) @@ -52,6 +57,27 @@ test('window boundaries are inclusive at both ends', () => { assert.equal(getActiveSeason(on(1, 16)), null); // Feb 16 just after }); +test('resolveSeasonTheme: off → null, pinned → that theme, auto → active season', () => { + const halloweenTs = on(9, 20).getTime(); // Oct 20 → halloween season + const offSeasonTs = on(5, 15).getTime(); // Jun 15 → no season + // 'off' never renders, regardless of date. + assert.equal(resolveSeasonTheme('off', halloweenTs), null); + // A pinned theme renders regardless of date (even off-season). + assert.equal(resolveSeasonTheme('christmas', offSeasonTs), 'christmas'); + // 'auto' tracks the active season for the given instant. + assert.equal(resolveSeasonTheme('auto', halloweenTs), 'halloween'); + assert.equal(resolveSeasonTheme('auto', offSeasonTs), null); +}); + +test('resolveSeasonTheme: auto re-evaluates as `now` advances across a boundary', () => { + // The same 'auto' override yields different themes at different instants — this + // is what the SeasonalEffect ticker relies on (incl. the switch-into-auto case + // where `now` must be current, not a stale mount value). + assert.equal(resolveSeasonTheme('auto', on(9, 20).getTime()), 'halloween'); // Oct 20 + assert.equal(resolveSeasonTheme('auto', on(11, 15).getTime()), 'christmas'); // Dec 15 + assert.equal(resolveSeasonTheme('auto', on(6, 4).getTime()), null); // Jul 4 +}); + test('SEASON_DATE_RANGES has a label for every scheduled theme', () => { assert.equal(SEASON_SCHEDULE.length, 11); const themes = SEASON_SCHEDULE.map((e) => e.theme); diff --git a/src/app/components/seasonal/seasonSchedule.ts b/src/app/components/seasonal/seasonSchedule.ts index 353e920ec..d424484e1 100644 --- a/src/app/components/seasonal/seasonSchedule.ts +++ b/src/app/components/seasonal/seasonSchedule.ts @@ -93,3 +93,18 @@ export function getActiveSeason(now: Date): SeasonTheme | null { const day = now.getDate(); return SEASON_SCHEDULE.find((entry) => entry.matches(month, day))?.theme ?? null; } + +/** A seasonal-theme setting value: the active season, a pinned theme, or off. */ +export type SeasonalOverride = SeasonTheme | 'auto' | 'off'; + +/** + * The theme to render for a `seasonalThemeOverride` at time `now` (epoch ms): + * 'off' → none, 'auto' → the active season for that instant, else the pinned + * theme. Kept pure (and unit-tested) so the decision is verifiable without + * mounting the React overlay. + */ +export function resolveSeasonTheme(override: SeasonalOverride, now: number): SeasonTheme | null { + if (override === 'off') return null; + if (override === 'auto') return getActiveSeason(new Date(now)); + return override; +} diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index b8df65083..84fd5d9c2 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -549,7 +549,11 @@ function Appearance() { value={seasonalThemeOverride ?? 'auto'} onChange={(v) => { setSeasonalThemeOverride(v); - if (v !== 'auto' && v !== 'off') setChatBackground('none'); + // Any active seasonal mode (incl. "auto") is mutually exclusive + // with a chat background — else picking it is a silent no-op, since + // SeasonalEffect suppresses the overlay while a background is set. + // Only "off" leaves the background alone. + if (v !== 'off') setChatBackground('none'); }} />