f816049fdf
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>
96 lines
2.7 KiB
TypeScript
96 lines
2.7 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;
|
||
}
|