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:
@@ -2,6 +2,8 @@ import React, { useMemo } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { zIndices } from '../../styles/zIndex';
|
||||
import { SeasonTheme } from './types';
|
||||
import { getActiveSeason } from './seasonSchedule';
|
||||
import {
|
||||
animSeasonFall,
|
||||
animLeafFall,
|
||||
@@ -16,48 +18,10 @@ import {
|
||||
animPixelBlink,
|
||||
} from './Seasonal.css';
|
||||
|
||||
export type SeasonTheme =
|
||||
| 'halloween'
|
||||
| 'christmas'
|
||||
| 'newyear'
|
||||
| 'autumn'
|
||||
| 'aprilfools'
|
||||
| 'lunar'
|
||||
| 'valentines'
|
||||
| 'stpatricks'
|
||||
| 'earthday'
|
||||
| 'deepspace'
|
||||
| 'arcade';
|
||||
|
||||
function getActiveSeason(now: Date): SeasonTheme | null {
|
||||
const m = now.getMonth() + 1; // 1-12
|
||||
const d = now.getDate();
|
||||
|
||||
// New Year takes highest priority (Dec 31 – Jan 2)
|
||||
if ((m === 12 && d === 31) || (m === 1 && d <= 2)) return 'newyear';
|
||||
// Valentine's Day (Feb 10–15)
|
||||
if (m === 2 && d >= 10 && d <= 15) return 'valentines';
|
||||
// St. Patrick's Day (March 15–18)
|
||||
if (m === 3 && d >= 15 && d <= 18) return 'stpatricks';
|
||||
// April Fool's (April 1)
|
||||
if (m === 4 && d === 1) return 'aprilfools';
|
||||
// Earth Day (April 20–23)
|
||||
if (m === 4 && d >= 20 && d <= 23) return 'earthday';
|
||||
// Lunar New Year (Jan 22 – Feb 5, approximate fixed window)
|
||||
if ((m === 1 && d >= 22) || (m === 2 && d <= 5)) return 'lunar';
|
||||
// International Video Game Day (Sept 12)
|
||||
if (m === 9 && d === 12) return 'arcade';
|
||||
// World Space Week (Oct 4–10)
|
||||
if (m === 10 && d >= 4 && d <= 10) return 'deepspace';
|
||||
// Halloween (Oct 15 – Nov 1)
|
||||
if ((m === 10 && d >= 15) || (m === 11 && d === 1)) return 'halloween';
|
||||
// Christmas (Dec 10–30)
|
||||
if (m === 12 && d >= 10) return 'christmas';
|
||||
// Autumn (Sept 21 – Oct 31, excluding Halloween/Deep Space windows above)
|
||||
if ((m === 9 && d >= 21) || (m === 10 && d <= 14)) return 'autumn';
|
||||
|
||||
return null;
|
||||
}
|
||||
// SeasonTheme + the date-window logic now live in leaf modules (single source
|
||||
// of truth, shared with the settings UI). Re-exported here for existing
|
||||
// importers that still reach for it from this file.
|
||||
export type { SeasonTheme };
|
||||
|
||||
// ─── Individual theme overlays ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { getActiveSeason, SEASON_SCHEDULE, SEASON_DATE_RANGES } from './seasonSchedule';
|
||||
import { SeasonTheme } from './types';
|
||||
|
||||
// Date(year, monthIndex0, day)
|
||||
const on = (monthIndex0: number, day: number): Date => new Date(2026, monthIndex0, day);
|
||||
|
||||
test('each theme activates on a representative day in its window', () => {
|
||||
const cases: Array<[Date, SeasonTheme]> = [
|
||||
[on(11, 31), 'newyear'], // Dec 31
|
||||
[on(0, 1), 'newyear'], // Jan 1
|
||||
[on(0, 25), 'lunar'], // Jan 25
|
||||
[on(1, 3), 'lunar'], // Feb 3
|
||||
[on(1, 12), 'valentines'], // Feb 12
|
||||
[on(2, 16), 'stpatricks'], // Mar 16
|
||||
[on(3, 1), 'aprilfools'], // Apr 1
|
||||
[on(3, 21), 'earthday'], // Apr 21
|
||||
[on(8, 12), 'arcade'], // Sep 12
|
||||
[on(8, 25), 'autumn'], // Sep 25
|
||||
[on(9, 20), 'halloween'], // Oct 20
|
||||
[on(10, 1), 'halloween'], // Nov 1
|
||||
[on(11, 15), 'christmas'], // Dec 15
|
||||
];
|
||||
for (const [date, expected] of cases) {
|
||||
assert.equal(getActiveSeason(date), expected, `${date.toDateString()} -> ${expected}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('priority order resolves overlapping windows (Deep Space outranks Autumn)', () => {
|
||||
// Oct 4-10 is inside Autumn's Oct<=14 window too; Deep Space comes first.
|
||||
assert.equal(getActiveSeason(on(9, 5)), 'deepspace'); // Oct 5
|
||||
// Oct 12 is past Deep Space -> falls through to Autumn.
|
||||
assert.equal(getActiveSeason(on(9, 12)), 'autumn');
|
||||
});
|
||||
|
||||
test('New Year outranks Lunar New Year on Jan 1-2', () => {
|
||||
assert.equal(getActiveSeason(on(0, 1)), 'newyear');
|
||||
// Jan 22+ is past New Year -> Lunar.
|
||||
assert.equal(getActiveSeason(on(0, 22)), 'lunar');
|
||||
});
|
||||
|
||||
test('returns null on an off-season day', () => {
|
||||
assert.equal(getActiveSeason(on(5, 15)), null); // Jun 15
|
||||
assert.equal(getActiveSeason(on(6, 4)), null); // Jul 4
|
||||
});
|
||||
|
||||
test('window boundaries are inclusive at both ends', () => {
|
||||
assert.equal(getActiveSeason(on(1, 10)), 'valentines'); // Feb 10 start
|
||||
assert.equal(getActiveSeason(on(1, 15)), 'valentines'); // Feb 15 end
|
||||
assert.equal(getActiveSeason(on(1, 16)), null); // Feb 16 just after
|
||||
});
|
||||
|
||||
test('SEASON_DATE_RANGES has a label for every scheduled theme', () => {
|
||||
assert.equal(SEASON_SCHEDULE.length, 11);
|
||||
const themes = SEASON_SCHEDULE.map((e) => e.theme);
|
||||
assert.equal(new Set(themes).size, 11); // unique
|
||||
for (const t of themes) {
|
||||
assert.ok(
|
||||
typeof SEASON_DATE_RANGES[t] === 'string' && SEASON_DATE_RANGES[t].length > 0,
|
||||
`missing date range for ${t}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Shared seasonal types. Kept in a leaf module so the schedule, the overlay
|
||||
// components (one per theme under ./themes/), and the settings UI can all import
|
||||
// them without circular dependencies.
|
||||
|
||||
export type SeasonTheme =
|
||||
| 'halloween'
|
||||
| 'christmas'
|
||||
| 'newyear'
|
||||
| 'autumn'
|
||||
| 'aprilfools'
|
||||
| 'lunar'
|
||||
| 'valentines'
|
||||
| 'stpatricks'
|
||||
| 'earthday'
|
||||
| 'deepspace'
|
||||
| 'arcade';
|
||||
|
||||
// Props every per-theme overlay component receives. `reduced` mirrors
|
||||
// `prefers-reduced-motion`: when true the overlay must render a static (no
|
||||
// animation) but still beautiful ambient version. The settings preview always
|
||||
// passes reduced=true, so the static form has to stand on its own.
|
||||
export type SeasonalOverlayProps = {
|
||||
reduced: boolean;
|
||||
};
|
||||
Reference in New Issue
Block a user