229 lines
11 KiB
JavaScript
229 lines
11 KiB
JavaScript
/**
|
|
* 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('═══════════════════════════════════════════════════════════════');
|
|
|