From d416c62b4cdfd9046acd416bb0167ffba8e598c4 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 24 Jul 2026 17:34:51 -0400 Subject: [PATCH] fix(seasonal): auto theme re-evaluates over time; auto clears chat background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The "auto" seasonal theme was computed once at mount, so a long-lived session never crossed a season/holiday-window boundary. SeasonalEffect now re-evaluates on an hourly ticker (auto mode only) AND refreshes on entering auto — the interval only runs while auto, so a stale mount-time timestamp would otherwise resurface on a pinned/off → auto switch (the exact frozen-at-mount bug, caught in review). The decision is extracted to a pure resolveSeasonTheme(override, now) in seasonSchedule.ts (removing an unsafe cast) and unit-tested. - Selecting seasonal "auto" while a chat background was set was a silent no-op: the seasonal picker only cleared the background for a *specific* theme, and the overlay is suppressed while a background is set. Now any active seasonal mode ("auto" included) clears the background; only "off" leaves it — symmetric with the background picker (which sets seasonal "off"). The overlay guard stays as a backstop for legacy persisted state. Bug-hunt findings from LOTUS_TODO. Three review passes (the 2nd caught the switch-into-auto staleness); +2 unit tests. Gate-green (tsc, eslint, prettier, 922 tests, build). Co-Authored-By: Claude Opus 4.8 --- .../components/seasonal/SeasonalEffect.tsx | 28 +++++++++++++------ .../seasonal/seasonSchedule.test.ts | 28 ++++++++++++++++++- src/app/components/seasonal/seasonSchedule.ts | 15 ++++++++++ src/app/features/settings/general/General.tsx | 6 +++- 4 files changed, 67 insertions(+), 10 deletions(-) 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'); }} />