From f816049fdff3d0dfc5df7ef47b5b24d6a8c00665 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 30 Jun 2026 19:28:28 -0400 Subject: [PATCH] feat(seasonal): show Auto activation dates in settings + single-source schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/seasonal/SeasonalEffect.tsx | 48 ++-------- .../seasonal/seasonSchedule.test.ts | 65 +++++++++++++ src/app/components/seasonal/seasonSchedule.ts | 95 +++++++++++++++++++ src/app/components/seasonal/types.ts | 24 +++++ src/app/features/settings/general/General.tsx | 10 +- 5 files changed, 199 insertions(+), 43 deletions(-) create mode 100644 src/app/components/seasonal/seasonSchedule.test.ts create mode 100644 src/app/components/seasonal/seasonSchedule.ts create mode 100644 src/app/components/seasonal/types.ts diff --git a/src/app/components/seasonal/SeasonalEffect.tsx b/src/app/components/seasonal/SeasonalEffect.tsx index 3617e0a73..7a968c870 100644 --- a/src/app/components/seasonal/SeasonalEffect.tsx +++ b/src/app/components/seasonal/SeasonalEffect.tsx @@ -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 ──────────────────────────────────────────────── diff --git a/src/app/components/seasonal/seasonSchedule.test.ts b/src/app/components/seasonal/seasonSchedule.test.ts new file mode 100644 index 000000000..b67200444 --- /dev/null +++ b/src/app/components/seasonal/seasonSchedule.test.ts @@ -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}`, + ); + } +}); diff --git a/src/app/components/seasonal/seasonSchedule.ts b/src/app/components/seasonal/seasonSchedule.ts new file mode 100644 index 000000000..353e920ec --- /dev/null +++ b/src/app/components/seasonal/seasonSchedule.ts @@ -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 = SEASON_SCHEDULE.reduce( + (acc, entry) => { + acc[entry.theme] = entry.dateRange; + return acc; + }, + {} as Record, +); + +/** + * 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; +} diff --git a/src/app/components/seasonal/types.ts b/src/app/components/seasonal/types.ts new file mode 100644 index 000000000..c5b5f6c55 --- /dev/null +++ b/src/app/components/seasonal/types.ts @@ -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; +}; diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 9b43dba9d..7720d7932 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -57,6 +57,7 @@ import { settingsAtom, } from '../../../state/settings'; import { SeasonalPreview, SeasonTheme } from '../../../components/seasonal/SeasonalEffect'; +import { SEASON_DATE_RANGES } from '../../../components/seasonal/seasonSchedule'; import { SettingTile } from '../../../components/setting-tile'; import { KeySymbol } from '../../../utils/key-symbol'; import { isMacOS } from '../../../utils/user-agent'; @@ -438,7 +439,7 @@ function Appearance() { > {opt.label} + {(opt.value === 'auto' || !isSpecial) && ( + + {opt.value === 'auto' + ? 'By calendar' + : SEASON_DATE_RANGES[opt.value as SeasonTheme]} + + )} ); })}