diff --git a/app/src/benchmarks/recalc.bench.ts b/app/src/benchmarks/recalc.bench.ts new file mode 100644 index 0000000..a87a3d9 --- /dev/null +++ b/app/src/benchmarks/recalc.bench.ts @@ -0,0 +1,21 @@ +import { bench, describe } from 'vitest'; +import { usePayrollStore } from '../hooks/usePayrollStore'; + +// setup store +const store = usePayrollStore.getState(); +store.setActiveYear(2025); +store.initializeYear(2025); + +describe('setTargetNet performance', () => { + bench('setTargetNet (month 1) - full recalc', () => { + store.setTargetNet(1, 3000); + }); + + bench('setTargetNet (month 6) - partial recalc', () => { + store.setTargetNet(6, 3000); + }); + + bench('setTargetNet (month 12) - minimal recalc', () => { + store.setTargetNet(12, 3000); + }); +}); diff --git a/app/src/hooks/usePayrollStore.test.ts b/app/src/hooks/usePayrollStore.test.ts index f71c8e8..33dca9c 100644 --- a/app/src/hooks/usePayrollStore.test.ts +++ b/app/src/hooks/usePayrollStore.test.ts @@ -50,4 +50,40 @@ describe('usePayrollStore', () => { // Feb Balance = Jan monthlyBonus + Feb monthlyBonus expect(feb.runningBalance).toBeCloseTo(jan.monthlyBonus + feb.monthlyBonus, 1); }); + + it('should produce identical results with partial recalculation vs full recalculation', () => { + const store = usePayrollStore.getState(); + store.resetData(); + store.initializeYear(2025); + + // Scenario A: Incremental updates (uses optimized partial recalc) + act(() => { + store.setTargetNet(1, 3000); + store.setTargetNet(6, 4000); + }); + + const partialLedger = JSON.parse(JSON.stringify(usePayrollStore.getState().years[2025].ledger)); + const resultingEvents = usePayrollStore.getState().salaryEvents; + + // Scenario B: Full recalculation from scratch + act(() => { + store.resetData(); + usePayrollStore.setState({ salaryEvents: resultingEvents }); + store.initializeYear(2025); + }); + + const fullLedger = usePayrollStore.getState().years[2025].ledger; + + expect(partialLedger.length).toBe(12); + expect(fullLedger.length).toBe(12); + + for (let i = 0; i < 12; i++) { + expect(partialLedger[i].targetNet).toBe(fullLedger[i].targetNet); + expect(partialLedger[i].grossSalary).toBe(fullLedger[i].grossSalary); + expect(partialLedger[i].netSalary).toBe(fullLedger[i].netSalary); + expect(partialLedger[i].monthlyBonus).toBe(fullLedger[i].monthlyBonus); + expect(partialLedger[i].provisions).toBe(fullLedger[i].provisions); + expect(partialLedger[i].runningBalance).toBe(fullLedger[i].runningBalance); + } + }); }); diff --git a/app/src/hooks/usePayrollStore.ts b/app/src/hooks/usePayrollStore.ts index d3f58a0..5e0ac13 100644 --- a/app/src/hooks/usePayrollStore.ts +++ b/app/src/hooks/usePayrollStore.ts @@ -235,11 +235,14 @@ const recalcLedgerForYear = ( yearData: YearData, year: number, employee: EmployeeInfo, - salaryEvents: SalaryEvent[] + salaryEvents: SalaryEvent[], + fromMonth: number = 1 ) => { const dependents = employee.dependents; return yearData.ledger.map(entry => { + if (entry.month < fromMonth) return entry; + const { isInEmployment, daysWorked, proRataFactor } = computeEmploymentForMonth( employee, year, @@ -393,10 +396,20 @@ export const usePayrollStore = create()( const newYears: PayrollState['years'] = {}; for (const [yKey, yData] of Object.entries(state.years)) { const y = Number(yKey); - newYears[y] = { - ...yData, - ledger: recalcLedgerForYear(yData, y, state.employee, salaryEvents), - }; + + if (y < year) { + newYears[y] = yData; + } else if (y === year) { + newYears[y] = { + ...yData, + ledger: recalcLedgerForYear(yData, y, state.employee, salaryEvents, month), + }; + } else { + newYears[y] = { + ...yData, + ledger: recalcLedgerForYear(yData, y, state.employee, salaryEvents, 1), + }; + } } return { @@ -620,13 +633,27 @@ export const usePayrollStore = create()( event, ]); + const [evYearStr, evMonthStr] = event.effectiveMonth.split('-'); + const evYear = Number(evYearStr); + const evMonth = Number(evMonthStr); + const newYears: PayrollState['years'] = {}; for (const [yKey, yData] of Object.entries(state.years)) { const y = Number(yKey); - newYears[y] = { - ...yData, - ledger: recalcLedgerForYear(yData, y, state.employee, salaryEvents), - }; + + if (y < evYear) { + newYears[y] = yData; + } else if (y === evYear) { + newYears[y] = { + ...yData, + ledger: recalcLedgerForYear(yData, y, state.employee, salaryEvents, evMonth), + }; + } else { + newYears[y] = { + ...yData, + ledger: recalcLedgerForYear(yData, y, state.employee, salaryEvents, 1), + }; + } } return { salaryEvents, years: newYears }; @@ -636,15 +663,42 @@ export const usePayrollStore = create()( removeSalaryEvent: eventId => { set(state => { + const removedEvent = state.salaryEvents.find(e => e.id === eventId); const salaryEvents = sortSalaryEvents(state.salaryEvents.filter(e => e.id !== eventId)); + let evYear = 0; + let evMonth = 1; + + if (removedEvent) { + const [y, m] = removedEvent.effectiveMonth.split('-'); + evYear = Number(y); + evMonth = Number(m); + } + const newYears: PayrollState['years'] = {}; for (const [yKey, yData] of Object.entries(state.years)) { const y = Number(yKey); - newYears[y] = { - ...yData, - ledger: recalcLedgerForYear(yData, y, state.employee, salaryEvents), - }; + + if (removedEvent) { + if (y < evYear) { + newYears[y] = yData; + } else if (y === evYear) { + newYears[y] = { + ...yData, + ledger: recalcLedgerForYear(yData, y, state.employee, salaryEvents, evMonth), + }; + } else { + newYears[y] = { + ...yData, + ledger: recalcLedgerForYear(yData, y, state.employee, salaryEvents, 1), + }; + } + } else { + newYears[y] = { + ...yData, + ledger: recalcLedgerForYear(yData, y, state.employee, salaryEvents), + }; + } } return { salaryEvents, years: newYears };