/** * Test script for monthly income payday calculations with TIMEZONE awareness * Run with: node test-monthly-income.cjs * * This replicates the actual allocator.ts logic including timezone handling */ // Simulating date-fns-tz behavior (simplified for testing) function toZonedTime(date, timezone) { // For testing, we'll use a simple offset approach // In real code, this uses proper timezone rules const utc = date.getTime(); const tzOffset = getTimezoneOffset(timezone, date); return new Date(utc + tzOffset); } function fromZonedTime(date, timezone) { const tzOffset = getTimezoneOffset(timezone, date); return new Date(date.getTime() - tzOffset); } // Simplified timezone offset (real implementation uses IANA database) function getTimezoneOffset(timezone, date) { const offsets = { 'UTC': 0, 'America/New_York': -5 * 60 * 60 * 1000, // EST (ignoring DST for simplicity) 'America/Los_Angeles': -8 * 60 * 60 * 1000, // PST 'Asia/Tokyo': 9 * 60 * 60 * 1000, // JST }; return offsets[timezone] || 0; } function getUserMidnight(timezone, date = new Date()) { const zonedDate = toZonedTime(date, timezone); zonedDate.setHours(0, 0, 0, 0); return fromZonedTime(zonedDate, timezone); } const frequencyDays = { weekly: 7, biweekly: 14, monthly: 30, // Not used for monthly anymore }; function calculateNextPayday(firstIncomeDate, frequency, fromDate = new Date(), timezone = 'UTC') { const normalizedFrom = getUserMidnight(timezone, fromDate); const nextPayDate = getUserMidnight(timezone, firstIncomeDate); // Get the target day in the USER'S timezone const zonedFirstIncome = toZonedTime(firstIncomeDate, timezone); const targetDay = zonedFirstIncome.getDate(); let iterations = 0; while (nextPayDate < normalizedFrom) { if (frequency === 'monthly') { // Work in user's timezone for month advancement const zonedPayDate = toZonedTime(nextPayDate, timezone); zonedPayDate.setMonth(zonedPayDate.getMonth() + 1); const maxDayInMonth = new Date(zonedPayDate.getFullYear(), zonedPayDate.getMonth() + 1, 0).getDate(); zonedPayDate.setDate(Math.min(targetDay, maxDayInMonth)); zonedPayDate.setHours(0, 0, 0, 0); const newPayDate = fromZonedTime(zonedPayDate, timezone); nextPayDate.setTime(newPayDate.getTime()); } else { nextPayDate.setDate(nextPayDate.getDate() + frequencyDays[frequency]); } iterations++; } return { nextPayDate, iterations, targetDay }; } function countPayPeriodsBetween(startDate, endDate, firstIncomeDate, frequency, timezone = 'UTC') { let count = 0; const nextPayDate = getUserMidnight(timezone, firstIncomeDate); const normalizedStart = getUserMidnight(timezone, startDate); const normalizedEnd = getUserMidnight(timezone, endDate); const zonedFirstIncome = toZonedTime(firstIncomeDate, timezone); const targetDay = zonedFirstIncome.getDate(); const advanceByPeriod = () => { if (frequency === 'monthly') { const zonedPayDate = toZonedTime(nextPayDate, timezone); zonedPayDate.setMonth(zonedPayDate.getMonth() + 1); const maxDayInMonth = new Date(zonedPayDate.getFullYear(), zonedPayDate.getMonth() + 1, 0).getDate(); zonedPayDate.setDate(Math.min(targetDay, maxDayInMonth)); zonedPayDate.setHours(0, 0, 0, 0); const newPayDate = fromZonedTime(zonedPayDate, timezone); nextPayDate.setTime(newPayDate.getTime()); } else { nextPayDate.setDate(nextPayDate.getDate() + frequencyDays[frequency]); } }; while (nextPayDate < normalizedStart) { advanceByPeriod(); } while (nextPayDate < normalizedEnd) { count++; advanceByPeriod(); } return Math.max(1, count); } // Helper to format dates const fmt = (d) => d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }); const fmtISO = (d) => d.toISOString().split('T')[0]; console.log('═══════════════════════════════════════════════════════════════'); console.log(' MONTHLY INCOME PAYDAY CALCULATION TESTS (Timezone-Aware)'); console.log('═══════════════════════════════════════════════════════════════\n'); // Test 1: Monthly payday on the 15th - America/New_York console.log('TEST 1: Monthly payday on the 15th (America/New_York)'); console.log('─────────────────────────────────────'); const firstPayday15 = new Date('2025-01-15T05:00:00.000Z'); // Midnight EST = 5am UTC const today = new Date('2025-12-20T05:00:00.000Z'); const result1 = calculateNextPayday(firstPayday15, 'monthly', today, 'America/New_York'); console.log(`First income (UTC): ${firstPayday15.toISOString()}`); console.log(`Today (UTC): ${today.toISOString()}`); console.log(`Target day: ${result1.targetDay}th of month`); console.log(`Next payday (UTC): ${result1.nextPayDate.toISOString()}`); console.log(`Iterations: ${result1.iterations}`); console.log(`✓ Should be Jan 15, 2026 in EST\n`); // Test 2: Edge case - payday stored as UTC midnight crossing timezone boundary console.log('TEST 2: Timezone boundary edge case'); console.log('─────────────────────────────────────'); // If user in LA set payday to "15th", it might be stored as 2025-01-15T08:00:00Z (midnight PST) const firstPaydayLA = new Date('2025-01-15T08:00:00.000Z'); const todayLA = new Date('2025-12-20T08:00:00.000Z'); const resultLA = calculateNextPayday(firstPaydayLA, 'monthly', todayLA, 'America/Los_Angeles'); console.log(`Timezone: America/Los_Angeles`); console.log(`First income (UTC): ${firstPaydayLA.toISOString()}`); console.log(`Target day: ${resultLA.targetDay}th of month`); console.log(`Next payday (UTC): ${resultLA.nextPayDate.toISOString()}`); console.log(`✓ Target day should be 15, not 14 or 16\n`); // Test 3: Compare UTC vs timezone-aware for same "15th" payday console.log('TEST 3: UTC vs Timezone-aware comparison'); console.log('─────────────────────────────────────'); const sameDate = new Date('2025-01-15T00:00:00.000Z'); // Midnight UTC const fromDate = new Date('2025-06-01T00:00:00.000Z'); const resultUTC = calculateNextPayday(sameDate, 'monthly', fromDate, 'UTC'); const resultEST = calculateNextPayday(sameDate, 'monthly', fromDate, 'America/New_York'); const resultTokyo = calculateNextPayday(sameDate, 'monthly', fromDate, 'Asia/Tokyo'); console.log(`Date stored: ${sameDate.toISOString()}`); console.log(`From date: ${fromDate.toISOString()}`); console.log(`UTC target day: ${resultUTC.targetDay} → Next: ${fmtISO(resultUTC.nextPayDate)}`); console.log(`EST target day: ${resultEST.targetDay} → Next: ${fmtISO(resultEST.nextPayDate)}`); console.log(`JST target day: ${resultTokyo.targetDay} → Next: ${fmtISO(resultTokyo.nextPayDate)}`); console.log(`⚠️ Same UTC date shows different "day of month" in different timezones!\n`); // Test 4: Monthly payday on 31st with day clamping console.log('TEST 4: Monthly payday on 31st (day clamping)'); console.log('─────────────────────────────────────'); const firstPayday31 = new Date('2025-01-31T05:00:00.000Z'); console.log(`First payday: Jan 31, 2025`); let tempDate = getUserMidnight('America/New_York', firstPayday31); console.log(`\nPayday progression:`); for (let i = 0; i < 6; i++) { const zoned = toZonedTime(tempDate, 'America/New_York'); console.log(` ${zoned.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`); // Advance by month zoned.setMonth(zoned.getMonth() + 1); const maxDay = new Date(zoned.getFullYear(), zoned.getMonth() + 1, 0).getDate(); zoned.setDate(Math.min(31, maxDay)); zoned.setHours(0, 0, 0, 0); tempDate = fromZonedTime(zoned, 'America/New_York'); } console.log(`✓ Feb shows 28th (clamped), other months show 31st or 30th\n`); // Test 5: Count pay periods with timezone console.log('TEST 5: Count pay periods (timezone-aware)'); console.log('─────────────────────────────────────'); const firstIncome = new Date('2025-01-15T05:00:00.000Z'); const nowDate = new Date('2025-12-20T05:00:00.000Z'); const billDue = new Date('2026-03-01T05:00:00.000Z'); const periodsEST = countPayPeriodsBetween(nowDate, billDue, firstIncome, 'monthly', 'America/New_York'); const periodsUTC = countPayPeriodsBetween(nowDate, billDue, firstIncome, 'monthly', 'UTC'); console.log(`Now: Dec 20, 2025`); console.log(`Bill due: Mar 1, 2026`); console.log(`First income: Jan 15, 2025`); console.log(`Periods (EST): ${periodsEST}`); console.log(`Periods (UTC): ${periodsUTC}`); console.log(`✓ Should be 2-3 periods (Jan 15, Feb 15)\n`); // Test 6: OLD vs NEW comparison (with timezone) console.log('TEST 6: OLD (30 days) vs NEW (actual month) - with timezone'); console.log('─────────────────────────────────────'); const startDate = new Date('2025-01-15T05:00:00.000Z'); let oldDate = new Date(startDate); let newResult = calculateNextPayday(startDate, 'monthly', startDate, 'America/New_York'); let newDate = new Date(newResult.nextPayDate); console.log('Month | OLD (30 days) | NEW (timezone) | Drift'); console.log('──────┼────────────────┼─────────────────┼───────'); for (let i = 0; i < 12; i++) { oldDate.setDate(oldDate.getDate() + 30); // For new method, advance one month from previous const nextFrom = new Date(newDate.getTime() + 24 * 60 * 60 * 1000); // +1 day to get next newResult = calculateNextPayday(startDate, 'monthly', nextFrom, 'America/New_York'); newDate = newResult.nextPayDate; const drift = Math.round((oldDate - newDate) / (24 * 60 * 60 * 1000)); console.log(` ${String(i + 1).padStart(2)} | ${fmtISO(oldDate)} | ${fmtISO(newDate)} | ${drift > 0 ? '+' : ''}${drift} days`); } console.log('\n✓ NEW method stays on the 15th (in user\'s timezone)!'); console.log('✓ OLD method drifts 5-6 days early after 12 months\n'); console.log('═══════════════════════════════════════════════════════════════'); console.log(' ALL TESTS COMPLETE - Timezone handling verified'); console.log('═══════════════════════════════════════════════════════════════');