diff --git a/.changelogs/v0.26.0.md b/.changelogs/v0.26.0.md new file mode 100644 index 0000000..ae51783 --- /dev/null +++ b/.changelogs/v0.26.0.md @@ -0,0 +1,61 @@ +# Release v0.26.0 - Smart Stock Fund Notifications + +Released: 2026-01-18 + +## Overview + +This release improves the user experience by intelligently suppressing stock fund action prompts when the US stock market is closed, and fixes a visual alignment issue in entry forms. + +## New Features + +### Market-Aware Stock Fund Notifications +- **Weekend detection**: Stock funds no longer prompt for action on Saturdays and Sundays +- **Holiday detection**: Stock funds skip prompts on all major US stock market holidays: + - New Year's Day (with weekend observance) + - Martin Luther King Jr. Day (3rd Monday of January) + - Presidents Day (3rd Monday of February) + - Good Friday (Friday before Easter) + - Memorial Day (Last Monday of May) + - Juneteenth (June 19, with weekend observance) + - Independence Day (July 4, with weekend observance) + - Labor Day (1st Monday of September) + - Thanksgiving (4th Thursday of November) + - Christmas (December 25, with weekend observance) +- **Applies to**: ActionableFundsBanner on dashboard and nav badge count +- **Other fund types unaffected**: Crypto, cash, and derivatives funds still prompt as normal (24/7 markets) + +### GitHub App Link +- Added link to the GitHub App installation page + +## Bug Fixes + +### Form Input Alignment +- Fixed an issue where the "Update first" wizard indicator caused the Equity input field to be pushed down relative to the Date field +- Applied consistent fixed-height label rows (`h-5`) across all form fields with wizard indicators +- Affects both Cash Balance and standard trading fund entry forms + +## Technical Details + +### New Utility Functions +- `isStockMarketClosed(date?)`: Check if US stock market is closed on a given date +- `isUSMarketHoliday(date)`: Internal helper for holiday detection +- `calculateEaster(year)`: Easter calculation for Good Friday determination + +### Files Changed +- `packages/web/src/utils/format.ts` - Added market closed detection utilities +- `packages/web/src/components/ActionableFundsBanner.tsx` - Filter stock funds on market closed days +- `packages/web/src/components/Layout.tsx` - Apply same filter to nav badge count +- `packages/web/src/components/EntryForm.tsx` - Fixed label row heights for alignment + +## Installation + +```bash +git clone https://github.com/atomantic/EscapeMint.git +cd EscapeMint +npm run setup +npm run dev +``` + +## Full Changelog + +**Full Diff**: https://github.com/atomantic/EscapeMint/compare/v0.25.0...v0.26.0 diff --git a/package-lock.json b/package-lock.json index cbf9ea9..f06fe1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "escapemint", - "version": "0.25.0", + "version": "0.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "escapemint", - "version": "0.25.0", + "version": "0.26.0", "license": "MIT", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index 6029a5a..76160c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "escapemint", - "version": "0.25.0", + "version": "0.26.0", "type": "module", "private": true, "description": "A local-first, open-source capital allocation engine for rules-based fund management", diff --git a/packages/web/src/components/ActionableFundsBanner.tsx b/packages/web/src/components/ActionableFundsBanner.tsx index 99f81eb..9c6801a 100644 --- a/packages/web/src/components/ActionableFundsBanner.tsx +++ b/packages/web/src/components/ActionableFundsBanner.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom' import { fetchActionableFunds, FUNDS_CHANGED_EVENT, type ActionableFund } from '../api/funds' import { getFundTypeFeatures } from '@escapemint/engine' import { useSettings } from '../contexts/SettingsContext' +import { isStockMarketClosed } from '../utils/format' // Days overdue threshold for high urgency styling (red border) const URGENCY_THRESHOLD_DAYS = 7 @@ -73,7 +74,17 @@ export function ActionableFundsBanner() { notifyActionableDismissed(newVisibleCount) } - const visibleFunds = actionableFunds.filter(f => !dismissed.has(f.id)) + // Check if stock market is closed (weekend or holiday) + // No memoization needed - lightweight check that updates correctly when data refreshes + const marketClosed = isStockMarketClosed() + + // Filter out dismissed funds and stock funds when market is closed + const visibleFunds = actionableFunds.filter(f => { + if (dismissed.has(f.id)) return false + // Skip stock funds on weekends/holidays since market is closed + if (marketClosed && f.fundType === 'stock') return false + return true + }) // Notify on initial load and when actionable funds change useEffect(() => { diff --git a/packages/web/src/components/EntryForm.tsx b/packages/web/src/components/EntryForm.tsx index 6d1e2ae..47cc1d2 100644 --- a/packages/web/src/components/EntryForm.tsx +++ b/packages/web/src/components/EntryForm.tsx @@ -106,9 +106,10 @@ export const parseFormulaValue = (input: string): number => { } // Wizard indicator component - animated arrow pointing to a field +// Uses absolute positioning to avoid affecting vertical layout function WizardIndicator({ label }: { label: string }) { return ( -
+
{label}
@@ -337,7 +338,9 @@ export function EntryForm({ formData, setFormData, existingEntries = [], baseFun {/* Row 1: Date, Cash Balance, Amount */}
- +
+ +
-
+
{wizardStep === 1 && }
@@ -465,7 +468,9 @@ export function EntryForm({ formData, setFormData, existingEntries = [], baseFun {/* Row 1: Date, Equity, Action, Amount */}
- +
+ +
-
+
@@ -593,7 +598,7 @@ export function EntryForm({ formData, setFormData, existingEntries = [], baseFun {marginEnabled && !isCashFund && (
-
+
{wizardStep === 2 && }
@@ -614,7 +619,9 @@ export function EntryForm({ formData, setFormData, existingEntries = [], baseFun

- +
+ +
{}) fetchActionableFunds(settings.testFundsMode).then(result => { if (result.data) { - // Filter out dismissed funds from the count + // Filter out dismissed funds and stock funds when market is closed + // Market status recalculates on each data fetch, which handles day transitions const dismissed = getDismissedFundIds() - const visibleCount = result.data.actionableFunds.filter(f => !dismissed.has(f.id)).length + const marketClosed = isStockMarketClosed() + const visibleCount = result.data.actionableFunds.filter(f => { + if (dismissed.has(f.id)) return false + // Skip stock funds on weekends/holidays since market is closed + if (marketClosed && f.fundType === 'stock') return false + return true + }).length setActionableFundsCount(visibleCount) } }).catch(() => {}) diff --git a/packages/web/src/utils/format.ts b/packages/web/src/utils/format.ts index baf63e7..e2587a9 100644 --- a/packages/web/src/utils/format.ts +++ b/packages/web/src/utils/format.ts @@ -118,3 +118,170 @@ export const getPriorEquity = (entries: { return equity } + +/** + * Check if the US stock market is closed on the given date. + * + * This check is performed using US Eastern Time (America/New_York), + * independent of the user's local browser timezone. Returns true for + * weekends and major US stock market holidays. + */ +export function isStockMarketClosed(date: Date = new Date()): boolean { + // Normalize the provided date to US Eastern Time calendar components + const usEasternFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'America/New_York', + year: 'numeric', + month: 'numeric', + day: 'numeric', + weekday: 'short' + }) + + const parts = usEasternFormatter.formatToParts(date) + + let year: number | undefined + let month: number | undefined + let day: number | undefined + let weekday: string | undefined + + for (const part of parts) { + if (part.type === 'year') { + year = Number(part.value) + } else if (part.type === 'month') { + month = Number(part.value) + } else if (part.type === 'day') { + day = Number(part.value) + } else if (part.type === 'weekday') { + weekday = part.value + } + } + + // Fallback to local-time behavior if parsing fails for any reason + if (!year || !month || !day || !weekday) { + const localDayOfWeek = date.getDay() + if (localDayOfWeek === 0 || localDayOfWeek === 6) { + return true + } + return isUSMarketHoliday(date) + } + + // Weekend check in US Eastern Time (Saturday/Sunday) + if (weekday === 'Sat' || weekday === 'Sun') { + return true + } + + // Construct a synthetic Date corresponding to the US Eastern calendar date. + // Using UTC here ensures isUSMarketHoliday operates on the correct year/month/day + // regardless of the user's local timezone offset. + const usEasternDate = new Date(Date.UTC(year, month - 1, day)) + + // Check for US stock market holidays + return isUSMarketHoliday(usEasternDate) +} + +/** + * Check if date is a US stock market holiday + * Includes: New Year's Day, MLK Day, Presidents Day, Good Friday, + * Memorial Day, Juneteenth, Independence Day, Labor Day, Thanksgiving, Christmas + */ +function isUSMarketHoliday(date: Date): boolean { + const year = date.getFullYear() + const month = date.getMonth() // 0-indexed + const day = date.getDate() + + // Helper to get observed date for fixed holidays + // If falls on Saturday, observed Friday. If Sunday, observed Monday. + // Uses Date arithmetic to safely handle month boundaries. + const getObservedDate = (m: number, d: number): { month: number; day: number } => { + const holiday = new Date(year, m, d) + const dow = holiday.getDay() + if (dow === 6) { + // Saturday -> observed on previous Friday + const observed = new Date(holiday) + observed.setDate(observed.getDate() - 1) + return { month: observed.getMonth(), day: observed.getDate() } + } + if (dow === 0) { + // Sunday -> observed on next Monday + const observed = new Date(holiday) + observed.setDate(observed.getDate() + 1) + return { month: observed.getMonth(), day: observed.getDate() } + } + return { month: m, day: d } + } + + // Helper to get nth weekday of month (e.g., 3rd Monday) + const getNthWeekdayOfMonth = (m: number, weekday: number, n: number): number => { + const firstDay = new Date(year, m, 1) + const firstWeekday = firstDay.getDay() + const daysUntilWeekday = (weekday - firstWeekday + 7) % 7 + return 1 + daysUntilWeekday + (n - 1) * 7 + } + + // Helper to get last weekday of month + const getLastWeekdayOfMonth = (m: number, weekday: number): number => { + const lastDay = new Date(year, m + 1, 0).getDate() + const lastDayOfWeek = new Date(year, m, lastDay).getDay() + const daysBack = (lastDayOfWeek - weekday + 7) % 7 + return lastDay - daysBack + } + + // New Year's Day (January 1, observed) + const newYears = getObservedDate(0, 1) + if (month === newYears.month && day === newYears.day) return true + + // Martin Luther King Jr. Day (3rd Monday of January) + if (month === 0 && day === getNthWeekdayOfMonth(0, 1, 3)) return true + + // Presidents Day (3rd Monday of February) + if (month === 1 && day === getNthWeekdayOfMonth(1, 1, 3)) return true + + // Good Friday (Friday before Easter) + const easter = calculateEaster(year) + const goodFriday = new Date(easter) + goodFriday.setDate(goodFriday.getDate() - 2) + if (month === goodFriday.getMonth() && day === goodFriday.getDate()) return true + + // Memorial Day (Last Monday of May) + if (month === 4 && day === getLastWeekdayOfMonth(4, 1)) return true + + // Juneteenth (June 19, observed) + const juneteenth = getObservedDate(5, 19) + if (month === juneteenth.month && day === juneteenth.day) return true + + // Independence Day (July 4, observed) + const july4 = getObservedDate(6, 4) + if (month === july4.month && day === july4.day) return true + + // Labor Day (1st Monday of September) + if (month === 8 && day === getNthWeekdayOfMonth(8, 1, 1)) return true + + // Thanksgiving (4th Thursday of November) + if (month === 10 && day === getNthWeekdayOfMonth(10, 4, 4)) return true + + // Christmas (December 25, observed) + const christmas = getObservedDate(11, 25) + if (month === christmas.month && day === christmas.day) return true + + return false +} + +/** + * Calculate Easter Sunday using the Anonymous Gregorian algorithm + */ +function calculateEaster(year: number): Date { + const a = year % 19 + const b = Math.floor(year / 100) + const c = year % 100 + const d = Math.floor(b / 4) + const e = b % 4 + const f = Math.floor((b + 8) / 25) + const g = Math.floor((b - f + 1) / 3) + const h = (19 * a + b - d - g + 15) % 30 + const i = Math.floor(c / 4) + const k = c % 4 + const l = (32 + 2 * e + 2 * i - h - k) % 7 + const m = Math.floor((a + 11 * h + 22 * l) / 451) + const month = Math.floor((h + l - 7 * m + 114) / 31) - 1 + const day = ((h + l - 7 * m + 114) % 31) + 1 + return new Date(year, month, day) +} diff --git a/pages/src/BacktestApp.tsx b/pages/src/BacktestApp.tsx index 27d5133..90d56a3 100644 --- a/pages/src/BacktestApp.tsx +++ b/pages/src/BacktestApp.tsx @@ -342,6 +342,16 @@ export function BacktestApp() {

Historical data: SPXL (3x Russell 1000), VTI (Total US Market), BRGNX (Russell 1000), TQQQ (3x NASDAQ), BTC (Bitcoin)

All calculations run in-browser using EscapeMint engine

+

+ + View on GitHub + +