feat(seasonal): show Auto activation dates in settings + single-source schedule

Settings never told the user which days "Auto" turns each seasonal theme on.
Extracted the date windows out of getActiveSeason into a shared SEASON_SCHEDULE
(seasonSchedule.ts) — the single source of truth for both the runtime Auto
selector and the settings UI, so displayed dates can't drift from real activation.

- seasonal/types.ts: SeasonTheme + SeasonalOverlayProps (leaf module).
- seasonal/seasonSchedule.ts: priority-ordered SEASON_SCHEDULE with human date
  ranges + SEASON_DATE_RANGES + getActiveSeason (behavior-preserving refactor).
- SeasonalEffect.tsx: consume the shared type/selector; re-export SeasonTheme.
- General.tsx: per-theme date caption under each swatch ("Oct 15 – Nov 1"), Auto
  reads "By calendar", and the section description explains it.
- seasonSchedule.test.ts (6): representative day per theme, overlap priority
  (Deep Space > Autumn, New Year > Lunar), inclusive boundaries, off-season null.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 19:28:28 -04:00
parent eafa353364
commit f816049fdf
5 changed files with 199 additions and 43 deletions
@@ -0,0 +1,95 @@
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;
}