Files
cinny/src/app/components/seasonal/seasonSchedule.ts
T
jaredandClaude Opus 4.8 d416c62b4c 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>
2026-07-24 17:34:51 -04:00

111 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}