fix(seasonal): auto theme re-evaluates over time; auto clears chat background
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
||||||
import { zIndices } from '../../styles/zIndex';
|
import { zIndices } from '../../styles/zIndex';
|
||||||
import { SeasonTheme } from './types';
|
import { SeasonTheme } from './types';
|
||||||
import { getActiveSeason } from './seasonSchedule';
|
import { resolveSeasonTheme } from './seasonSchedule';
|
||||||
import { HalloweenOverlay } from './themes/Halloween';
|
import { HalloweenOverlay } from './themes/Halloween';
|
||||||
import { ChristmasOverlay } from './themes/Christmas';
|
import { ChristmasOverlay } from './themes/Christmas';
|
||||||
import { NewYearOverlay } from './themes/NewYear';
|
import { NewYearOverlay } from './themes/NewYear';
|
||||||
@@ -96,13 +96,25 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
|
|||||||
export function SeasonalEffect() {
|
export function SeasonalEffect() {
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const reduced = useReducedMotion();
|
const reduced = useReducedMotion();
|
||||||
|
const override = settings.seasonalThemeOverride ?? 'auto';
|
||||||
|
|
||||||
const theme = useMemo<SeasonTheme | null>(() => {
|
// In auto mode, re-evaluate hourly so a long-lived session crosses a
|
||||||
const override = settings.seasonalThemeOverride ?? 'auto';
|
// season/holiday-window boundary (e.g. into a new day) without a reload —
|
||||||
if (override === 'off') return null;
|
// otherwise the active season is frozen at the value it had on mount.
|
||||||
if (override === 'auto') return getActiveSeason(new Date());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
return override as SeasonTheme;
|
useEffect(() => {
|
||||||
}, [settings.seasonalThemeOverride]);
|
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<SeasonTheme | null>(
|
||||||
|
() => resolveSeasonTheme(override, now),
|
||||||
|
[override, now],
|
||||||
|
);
|
||||||
|
|
||||||
if (!theme) return null;
|
if (!theme) return null;
|
||||||
// Suppress seasonal overlay when a chat background is active — both running simultaneously
|
// Suppress seasonal overlay when a chat background is active — both running simultaneously
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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';
|
import { SeasonTheme } from './types';
|
||||||
|
|
||||||
// Date(year, monthIndex0, day)
|
// 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
|
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', () => {
|
test('SEASON_DATE_RANGES has a label for every scheduled theme', () => {
|
||||||
assert.equal(SEASON_SCHEDULE.length, 11);
|
assert.equal(SEASON_SCHEDULE.length, 11);
|
||||||
const themes = SEASON_SCHEDULE.map((e) => e.theme);
|
const themes = SEASON_SCHEDULE.map((e) => e.theme);
|
||||||
|
|||||||
@@ -93,3 +93,18 @@ export function getActiveSeason(now: Date): SeasonTheme | null {
|
|||||||
const day = now.getDate();
|
const day = now.getDate();
|
||||||
return SEASON_SCHEDULE.find((entry) => entry.matches(month, day))?.theme ?? null;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -549,7 +549,11 @@ function Appearance() {
|
|||||||
value={seasonalThemeOverride ?? 'auto'}
|
value={seasonalThemeOverride ?? 'auto'}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setSeasonalThemeOverride(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');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user