- 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>
111 lines
3.4 KiB
TypeScript
111 lines
3.4 KiB
TypeScript
import { SeasonTheme } from './types';
|
||
|
||
/**
|
||
* Single source of truth for when each seasonal theme auto-activates.
|
||
*
|
||
* Both `getActiveSeason` (the runtime "Auto" selector) and the settings UI read
|
||
* this list, so the date windows shown to the user can never drift from the
|
||
* dates actually used. Order matters: it is the activation PRIORITY — the first
|
||
* entry whose window matches wins (e.g. Deep Space outranks Autumn in their
|
||
* early-October overlap).
|
||
*/
|
||
export type SeasonScheduleEntry = {
|
||
theme: SeasonTheme;
|
||
/** Human-readable activation window for display in settings. */
|
||
dateRange: string;
|
||
/** Whether this theme is active on the given month (1-12) and day (1-31). */
|
||
matches: (month: number, day: number) => boolean;
|
||
};
|
||
|
||
export const SEASON_SCHEDULE: SeasonScheduleEntry[] = [
|
||
{
|
||
theme: 'newyear',
|
||
dateRange: 'Dec 31 – Jan 2',
|
||
matches: (m, d) => (m === 12 && d === 31) || (m === 1 && d <= 2),
|
||
},
|
||
{
|
||
theme: 'valentines',
|
||
dateRange: 'Feb 10 – 15',
|
||
matches: (m, d) => m === 2 && d >= 10 && d <= 15,
|
||
},
|
||
{
|
||
theme: 'stpatricks',
|
||
dateRange: 'Mar 15 – 18',
|
||
matches: (m, d) => m === 3 && d >= 15 && d <= 18,
|
||
},
|
||
{
|
||
theme: 'aprilfools',
|
||
dateRange: 'Apr 1',
|
||
matches: (m, d) => m === 4 && d === 1,
|
||
},
|
||
{
|
||
theme: 'earthday',
|
||
dateRange: 'Apr 20 – 23',
|
||
matches: (m, d) => m === 4 && d >= 20 && d <= 23,
|
||
},
|
||
{
|
||
theme: 'lunar',
|
||
dateRange: 'Jan 22 – Feb 5',
|
||
matches: (m, d) => (m === 1 && d >= 22) || (m === 2 && d <= 5),
|
||
},
|
||
{
|
||
theme: 'arcade',
|
||
dateRange: 'Sep 12',
|
||
matches: (m, d) => m === 9 && d === 12,
|
||
},
|
||
{
|
||
theme: 'deepspace',
|
||
dateRange: 'Oct 4 – 10',
|
||
matches: (m, d) => m === 10 && d >= 4 && d <= 10,
|
||
},
|
||
{
|
||
theme: 'halloween',
|
||
dateRange: 'Oct 15 – Nov 1',
|
||
matches: (m, d) => (m === 10 && d >= 15) || (m === 11 && d === 1),
|
||
},
|
||
{
|
||
theme: 'christmas',
|
||
dateRange: 'Dec 10 – 30',
|
||
matches: (m, d) => m === 12 && d >= 10,
|
||
},
|
||
{
|
||
theme: 'autumn',
|
||
dateRange: 'Sep 21 – Oct 14',
|
||
matches: (m, d) => (m === 9 && d >= 21) || (m === 10 && d <= 14),
|
||
},
|
||
];
|
||
|
||
/** Map of theme → human-readable activation window (for settings captions). */
|
||
export const SEASON_DATE_RANGES: Record<SeasonTheme, string> = SEASON_SCHEDULE.reduce(
|
||
(acc, entry) => {
|
||
acc[entry.theme] = entry.dateRange;
|
||
return acc;
|
||
},
|
||
{} as Record<SeasonTheme, string>,
|
||
);
|
||
|
||
/**
|
||
* The seasonal theme that should be active on `now`, or null if none. First
|
||
* matching entry in SEASON_SCHEDULE priority order wins.
|
||
*/
|
||
export function getActiveSeason(now: Date): SeasonTheme | null {
|
||
const month = now.getMonth() + 1; // 1-12
|
||
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;
|
||
}
|