Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
95 commits
Select commit Hold shift + click to select a range
fe0ac3e
Proof of Concept and Implementation Plan
code-with-jov Sep 6, 2025
628a582
feat: Add pay period support to months module
cursoragent Sep 6, 2025
1fb9c1a
Refactor: Move pay period logic to dedicated module
cursoragent Sep 6, 2025
732d02f
feat: Add pay period configuration table and types
cursoragent Sep 6, 2025
bc43614
Shared date utility
code-with-jov Sep 6, 2025
b12640e
Implement pay period support in month utilities
code-with-jov Sep 8, 2025
7917508
Merge pull request #12 from code-with-jov/cursor/implement-pay-period…
code-with-jov Sep 8, 2025
3fa12ad
Quick Correction
code-with-jov Sep 8, 2025
ad08a65
Merge pull request #15 from code-with-jov/cursor/implement-pay-period…
code-with-jov Sep 8, 2025
6049602
Merge pay_periods_13_99 into cursor/implement-pay-period-phases-1-2-a…
code-with-jov Sep 8, 2025
52b8436
feat: Add pay period settings and feature flag
cursoragent Sep 8, 2025
ea4fb58
feat: Add pay period display and configuration
cursoragent Sep 9, 2025
fbe3d19
Update implementation plan for pay period UI components
cursoragent Sep 9, 2025
f45c2c8
Refactor pay period settings and UI components for improved user expe…
code-with-jov Sep 10, 2025
6af7adb
Merge pull request #16 from code-with-jov/cursor/review-and-complete-…
code-with-jov Sep 10, 2025
896494b
Merge pull request #13 from code-with-jov/cursor/implement-pay-period…
code-with-jov Sep 10, 2025
b94f0b8
Remove all proof of concept related files and tests, including config…
code-with-jov Sep 10, 2025
2dbfa85
Merge latest changes
code-with-jov Sep 13, 2025
a8127aa
Refactor Pay Period display logic in budget components
code-with-jov Sep 15, 2025
73a038f
Remove console.log and add additional pay period test
code-with-jov Sep 15, 2025
8449961
Unified pay period config plan
code-with-jov Sep 19, 2025
d0fd559
Initial Phase 1 Commit
code-with-jov Sep 19, 2025
fac9ee3
Add Bimonthly; Add loggers for flow debugging and understanding
code-with-jov Sep 21, 2025
c35833c
Merge branch 'pay_periods_13_99' into pull_in_latest_changes
code-with-jov Sep 21, 2025
7ec1081
Merge pull request #20 from code-with-jov/pull_in_latest_changes
code-with-jov Sep 21, 2025
5127781
Merge pull request #21 from code-with-jov/pull_in_latest_changes
code-with-jov Sep 21, 2025
e6a10ac
Updated devcontainer.json
code-with-jov Sep 22, 2025
f16fd90
Updated devcontainer.json
code-with-jov Sep 22, 2025
4c03d49
Merge pull request #22 from code-with-jov/master
code-with-jov Sep 25, 2025
81c3762
Align Pay Periods with Calendar Years so that MM = 13 is always the f…
code-with-jov Sep 25, 2025
d8488b7
Source of truth for Pay Period config
code-with-jov Sep 27, 2025
826c960
Merge pull request #19 from code-with-jov/pay_periods_13_99_unified_c…
code-with-jov Sep 27, 2025
3d81582
Resolve Type check issues
code-with-jov Sep 28, 2025
273de0f
Merge pull request #23 from code-with-jov/pay_periods_13_99_unified_c…
code-with-jov Sep 28, 2025
b698de8
Fix pay period toggle navigation and Today button
code-with-jov Sep 30, 2025
53382dc
Merge pull request #24 from code-with-jov/pay_periods_13_99_unified_c…
code-with-jov Sep 30, 2025
e3b6ffc
Merge pull request #25 from code-with-jov/master
code-with-jov Sep 30, 2025
06092eb
Merge pull request #27 from code-with-jov/master
code-with-jov Oct 2, 2025
243b32f
Merge branch 'pay_periods_13_99_unified_configs' into pay_periods_13_99
code-with-jov Oct 2, 2025
31feae3
Mobile Pay Period Flow
code-with-jov Oct 4, 2025
020662f
Add Release Note and correct getMonthDateRange fallback logic
code-with-jov Oct 4, 2025
048b11e
Merge pull request #28 from code-with-jov/pay_periods_13_99_unified_c…
code-with-jov Oct 4, 2025
d9c7db1
Use nameForMonth as falllback
code-with-jov Oct 4, 2025
3bea6d1
Fix Mobile Monthpicker to rerender when pay period configs change
code-with-jov Oct 5, 2025
3b2b174
Initial Test and Plan clean up
code-with-jov Oct 5, 2025
77f0f82
Pay Period Config Hook
code-with-jov Oct 5, 2025
e371319
Console log cleanups, Consolidate Prefs call
code-with-jov Oct 5, 2025
2c5e320
Merge pull request #29 from code-with-jov/pay_periods_13_99_unified_c…
code-with-jov Oct 5, 2025
488588f
Revert "Pay Period Config Hook"
code-with-jov Oct 5, 2025
16c5ba8
Architecture Description Update
code-with-jov Oct 5, 2025
34000af
Docker in codespace
code-with-jov Oct 5, 2025
98d1f52
Merge pull request #30 from code-with-jov/master
code-with-jov Oct 7, 2025
7388b9a
Refresh Mobile
code-with-jov Oct 7, 2025
b9bf073
Merge pull request #31 from code-with-jov/pay_periods_13_99_unified_c…
code-with-jov Oct 7, 2025
acc2255
Merge branch 'pay_periods_13_99_unified_configs' into merge-branch
code-with-jov Oct 10, 2025
916482e
Merge pull request #32 from code-with-jov/merge-branch
code-with-jov Oct 10, 2025
f9f9054
Mobile Today Button fix
code-with-jov Oct 10, 2025
89ab073
Merge pull request #33 from code-with-jov/pay_periods_13_99_unified_c…
code-with-jov Oct 10, 2025
acfdc51
Code Clean Up
code-with-jov Oct 11, 2025
3142fb5
Code Clean Up
code-with-jov Oct 11, 2025
9bf1605
Merge pull request #34 from code-with-jov/pay_periods_13_99_unified_c…
code-with-jov Oct 11, 2025
86f143a
Code Clean Up
code-with-jov Oct 11, 2025
e8fbb12
Merge pull request #35 from code-with-jov/pay_periods_13_99_unified_c…
code-with-jov Oct 11, 2025
11d9dd2
Code Clean Up
code-with-jov Oct 11, 2025
ebb5a38
Merge pull request #36 from code-with-jov/pay_periods_13_99_unified_c…
code-with-jov Oct 11, 2025
7d5801a
[autofix.ci] apply automated fixes
autofix-ci[bot] Oct 11, 2025
10120ee
Merge pull request #37 from code-with-jov/pay_periods_13_99
code-with-jov Oct 11, 2025
8110d03
Remove logger
code-with-jov Oct 13, 2025
fc3bab6
Fix Visual Regression
code-with-jov Oct 13, 2025
837b8f9
Merge pull request #38 from code-with-jov/pay_periods_13_99_unified_c…
code-with-jov Oct 13, 2025
91dc729
Separate utility methods for mobile and desktop
code-with-jov Oct 14, 2025
8a7abca
Merge pull request #39 from code-with-jov/master
code-with-jov Oct 14, 2025
152a4d3
Merge pull request #40 from code-with-jov/pay_periods_13_99_unified_c…
code-with-jov Oct 14, 2025
383dfd5
Special Character Correction
code-with-jov Oct 14, 2025
b646bd4
Merge branch 'pay_periods_13_99_unified_configs' of https://github.co…
code-with-jov Oct 14, 2025
349ffa8
Merge pull request #41 from code-with-jov/pay_periods_13_99_unified_c…
code-with-jov Oct 14, 2025
f3a4b64
Align mobile with getMonthTextWithYear
code-with-jov Oct 15, 2025
fd2808c
Merge pull request #42 from code-with-jov/pay_periods_13_99_unified_c…
code-with-jov Oct 15, 2025
c526340
[autofix.ci] apply automated fixes
autofix-ci[bot] Oct 15, 2025
bcc2ee4
GetMonthTextWithYear
code-with-jov Oct 15, 2025
7300959
Merge pull request #43 from code-with-jov/pay_periods_13_99_unified_c…
code-with-jov Oct 15, 2025
f5291d6
Merge pull request #44 from code-with-jov/master
code-with-jov Oct 18, 2025
3985980
Custom Cache Key
code-with-jov Oct 18, 2025
f51dc91
Merge pull request #45 from code-with-jov/pay_periods_13_99_unified_c…
code-with-jov Oct 18, 2025
c5a9bbc
Merge pull request #46 from code-with-jov/master
code-with-jov Oct 25, 2025
024c67d
Increase timeout for budget to load
code-with-jov Oct 26, 2025
8b690b4
Merge pull request #47 from code-with-jov/pay_periods_13_99_unified_c…
code-with-jov Oct 26, 2025
573fc29
[autofix.ci] apply automated fixes
autofix-ci[bot] Oct 26, 2025
4324306
Fix date utility functions to properly handle pay period IDs
claude Oct 29, 2025
27d77a2
Add comprehensive root cause analysis and fix summary
claude Oct 29, 2025
39830c9
[bug fix] Handle pay periods in _parse to resolve schedule template a…
code-with-jov Nov 1, 2025
b519fe4
Resolve Pay Period with Scheduled Templates Issues
code-with-jov Nov 2, 2025
9fff828
Merge pull request #49 from code-with-jov/master
code-with-jov Nov 2, 2025
10a72f7
Merge pull request #48 from code-with-jov/claude/investigate-pay-peri…
code-with-jov Nov 2, 2025
462c7d1
[autofix.ci] apply automated fixes
autofix-ci[bot] Nov 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
646 changes: 646 additions & 0 deletions CLAUDE.MD

Large diffs are not rendered by default.

288 changes: 288 additions & 0 deletions PAY_PERIOD_SCHEDULE_ANALYSIS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
# Root Cause Analysis: Pay Periods Not Working with Schedule-Based Budget Templates

**Branch Analyzed:** `pay_periods_13_99_unified_configs`
**Date:** 2025-10-29
**Issue:** Pay periods (13-99) fail to work correctly with Schedule-based budget templates

---

## Executive Summary

The newly introduced pay periods fail when used with Schedule-based budget templates because **critical date/time utility functions don't handle pay period IDs correctly**. These functions incorrectly parse pay period IDs like `"2024-13"` as invalid calendar dates (e.g., month 13 becomes January of the next year), causing schedule calculations to fail.

---

## The Root Cause

### Location

`packages/loot-core/src/shared/date-utils.ts:66-67`

### The Problem

When a pay period ID like `"2024-13"` (first pay period of 2024) is parsed by the shared date parsing utility:

```typescript
const [year, month, day] = value.split('-');
// year = "2024", month = "13", day = undefined

if (day != null) {
return new Date(parseInt(year), parseInt(month) - 1, parseInt(day), 12);
} else if (month != null) {
return new Date(parseInt(year), parseInt(month) - 1, 1, 12);
// Creates: new Date(2024, 12, 1, 12)
// JavaScript month 12 (0-indexed) = January 2025!
}
```

**Result:**

- `"2024-13"` → Parsed as **January 1, 2025** ❌
- `"2024-14"` → Parsed as **February 1, 2025** ❌
- `"2024-15"` → Parsed as **March 1, 2025** ❌
- And so on...

The first pay period of 2024 gets incorrectly treated as **January 2025**, not as its actual date range (which could be any dates depending on the pay period configuration).

---

## Impact on Schedule Templates

### How Schedules Use Date Calculations

The schedule template code (`packages/loot-core/src/server/budget/schedule-template.ts`) relies heavily on date difference calculations to determine:

1. Whether a schedule is in the past or future
2. How much to budget for recurring schedules
3. The interval between schedule occurrences

### Critical Failure Points

#### 1. **Schedule Date Validation** (schedule-template.ts:84-90)

```typescript
const num_months = monthUtils.differenceInCalendarMonths(
next_date_string, // e.g., "2024-01-15" (a schedule in January 2024)
current_month, // e.g., "2024-13" → incorrectly parsed as Jan 2025!
);

if (num_months < 0) {
// This incorrectly triggers!
// Jan 2024 - Jan 2025 = -12 months
errors.push(`Schedule ${template.name} is in the Past.`);
}
```

When budgeting for pay period `"2024-13"` (which might actually be in January 2024):

- Schedule's next date: `"2024-01-15"` → Parsed as January 15, 2024
- Current month: `"2024-13"` → **Incorrectly** parsed as January 1, 2025
- Difference: `Jan 2024 - Jan 2025 = -12 months`
- Result: Schedule is incorrectly marked as "in the past" and excluded! ❌

#### 2. **Interval Calculations** (schedule-template.ts:212-234)

For weekly/daily schedules, the code calculates the interval in months:

```typescript
// For weekly schedules (line 212-218)
prevDate = monthUtils.subWeeks(
schedule.next_date_string,
schedule.target_interval,
);
intervalMonths = monthUtils.differenceInCalendarMonths(
schedule.next_date_string,
prevDate,
);
```

This calculation fails when the current month is a pay period because:

- `subWeeks` doesn't understand pay periods
- `differenceInCalendarMonths` incorrectly parses pay period IDs

---

## Why It Works for Some Schedules

The bug manifests inconsistently because the incorrect parsing sometimes "accidentally" works:

### ✅ Works When:

- Schedule date happens to fall in a range where the misparsed pay period date still makes the calculation correct
- The schedule is far enough in the future that `num_months` stays positive despite the parsing error
- Example: If current pay period is `"2024-13"` (parsed as Jan 2025), a schedule for `"2025-06-15"` would have `num_months = 5` months (June 2025 - Jan 2025), which might still work

### ❌ Fails When:

- Schedule date falls within the "misparsed" date range, making `num_months < 0`
- Schedule appears to be in the past when it's actually current/future
- Example: Pay period `"2024-13"` (parsed as Jan 2025) with schedule on `"2024-01-15"` calculates as -12 months

---

## Affected Functions

The following functions in `packages/loot-core/src/shared/months.ts` **DO NOT handle pay periods** but are used by schedule templates:

| Function | Line | Used By Schedules | Severity | Status |
| ---------------------------- | ------- | --------------------- | ----------- | -------- |
| `differenceInCalendarMonths` | 170-175 | ✅ Lines 84, 216, 229 | 🔴 CRITICAL | ✅ FIXED |
| `differenceInCalendarDays` | 177-182 | ✅ Line 141 | 🔴 HIGH | ✅ FIXED |
| `addWeeks` | 166-168 | ✅ Line 320 | 🟡 MEDIUM | ✅ FIXED |
| `subWeeks` | 195-197 | ✅ Line 212 | 🟡 MEDIUM | ✅ FIXED |
| `addDays` | 203-205 | ✅ Line 127 | 🟡 MEDIUM | ✅ FIXED |
| `subDays` | 207-209 | ✅ Line 225 | 🟡 MEDIUM | ✅ FIXED |

---

## Functions Already Handling Pay Periods Correctly

These functions in `months.ts` **already handle pay periods** using the established pattern:

✅ `nextMonth` - Checks `isPayPeriod()` and calls `nextPayPeriod()`
✅ `prevMonth` - Checks `isPayPeriod()` and calls `prevPayPeriod()`
✅ `addMonths` - Checks `isPayPeriod()` and calls `addPayPeriods()`
✅ `subMonths` - Checks `isPayPeriod()` and calls `addPayPeriods()` with negative n
✅ `bounds` - Checks `isPayPeriod()` and uses `generatePayPeriods()`
✅ `_range` - Checks `isPayPeriod()` and calls `generatePayPeriodRange()`

---

## Implemented Solutions

### 1. Fixed `differenceInCalendarMonths` ✅

Converts pay periods to their actual date ranges before calculating differences:

```typescript
export function differenceInCalendarMonths(
month1: DateLike,
month2: DateLike,
): number {
const str1 =
typeof month1 === 'string' ? month1 : d.format(_parse(month1), 'yyyy-MM');
const str2 =
typeof month2 === 'string' ? month2 : d.format(_parse(month2), 'yyyy-MM');

// If either is a pay period, convert to actual start dates
if (isPayPeriod(str1) || isPayPeriod(str2)) {
const config = getPayPeriodConfig();
const date1 = isPayPeriod(str1)
? getMonthStartDate(str1, config)
: _parse(month1);
const date2 = isPayPeriod(str2)
? getMonthStartDate(str2, config)
: _parse(month2);
return d.differenceInCalendarMonths(date1, date2);
}

return d.differenceInCalendarMonths(_parse(month1), _parse(month2));
}
```

### 2. Fixed `differenceInCalendarDays` ✅

Similar approach - converts pay periods to actual dates:

```typescript
export function differenceInCalendarDays(
month1: DateLike,
month2: DateLike,
): number {
const str1 =
typeof month1 === 'string'
? month1
: d.format(_parse(month1), 'yyyy-MM-dd');
const str2 =
typeof month2 === 'string'
? month2
: d.format(_parse(month2), 'yyyy-MM-dd');

if (isPayPeriod(str1) || isPayPeriod(str2)) {
const config = getPayPeriodConfig();
const date1 = isPayPeriod(str1)
? getMonthStartDate(str1, config)
: _parse(month1);
const date2 = isPayPeriod(str2)
? getMonthStartDate(str2, config)
: _parse(month2);
return d.differenceInCalendarDays(date1, date2);
}

return d.differenceInCalendarDays(_parse(month1), _parse(month2));
}
```

### 3. Fixed Week/Day Arithmetic Functions ✅

For `addWeeks`, `subWeeks`, `addDays`, `subDays` - converted pay period IDs to their start date before performing operations:

```typescript
export function addWeeks(date: DateLike, n: number): string {
// Convert pay period to its start date before performing week arithmetic
const dateStr =
typeof date === 'string' ? date : d.format(_parse(date), 'yyyy-MM-dd');

if (isPayPeriod(dateStr)) {
const config = getPayPeriodConfig();
const startDate = getMonthStartDate(dateStr, config);
return d.format(d.addWeeks(startDate, n), 'yyyy-MM-dd');
}

return d.format(d.addWeeks(_parse(date), n), 'yyyy-MM-dd');
}
```

---

## Testing Strategy

After implementing fixes, test with:

1. **Schedule in same calendar month as pay period:**
- Pay period: `"2024-13"` (e.g., Jan 1-14, 2024)
- Schedule: Next date `"2024-01-10"`
- Expected: Schedule should be recognized as current/upcoming

2. **Schedule in future calendar month:**
- Pay period: `"2024-13"` (e.g., Jan 1-14, 2024)
- Schedule: Next date `"2024-03-15"`
- Expected: Correct month difference calculation

3. **Weekly/Daily schedules:**
- Verify interval calculations work correctly
- Ensure monthly targets are calculated properly

4. **Edge cases:**
- Pay period spanning month boundary
- Multiple pay periods in same calendar month
- Different pay frequencies (weekly, biweekly, semimonthly, monthly)

---

## Files Changed

### Primary Changes

1. `packages/loot-core/src/shared/months.ts`
- ✅ Fixed `differenceInCalendarMonths` (line 179-201)
- ✅ Fixed `differenceInCalendarDays` (line 203-225)
- ✅ Fixed `addWeeks` (line 166-177)
- ✅ Fixed `subWeeks` (line 238-249)
- ✅ Fixed `addDays` (line 255-266)
- ✅ Fixed `subDays` (line 268-279)

---

## Conclusion

The pay period feature is well-architected with clear separation between calendar months and pay periods. The issue was that **not all month utility functions were updated to handle pay periods**. The fix follows the established pattern already used successfully in functions like `addMonths`, `nextMonth`, etc.

**Priority:** HIGH - This breaks a critical budgeting feature (schedule-based templates) when pay periods are enabled.

**Complexity:** LOW - The fix pattern is already established in the codebase.

**Risk:** LOW - Changes are isolated to utility functions with clear test coverage paths.

**Status:** ✅ FIXED - All affected functions have been updated to properly handle pay period IDs.
4 changes: 3 additions & 1 deletion packages/desktop-client/e2e/command-bar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ test.describe('Command bar', () => {
await page.mouse.move(0, 0);

// ensure page is loaded
await expect(page.getByTestId('budget-table')).toBeVisible();
await expect(page.getByTestId('budget-table')).toBeVisible({
timeout: 10000,
});
await expect(page.getByRole('button', { name: 'Add group' })).toBeVisible({
timeout: 10000,
});
Expand Down
22 changes: 22 additions & 0 deletions packages/desktop-client/src/components/budget/BudgetPageHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
// @ts-strict-ignore
import React, { type ComponentProps, memo } from 'react';
import { Trans } from 'react-i18next';

import { View } from '@actual-app/components/view';

import { MonthPicker } from './MonthPicker';
import { getScrollbarWidth } from './util';

import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';

type BudgetPageHeaderProps = {
startMonth: string;
Expand All @@ -20,6 +23,9 @@ export const BudgetPageHeader = memo<BudgetPageHeaderProps>(
const [categoryExpandedStatePref] = useGlobalPref('categoryExpandedState');
const categoryExpandedState = categoryExpandedStatePref ?? 0;
const offsetMultipleMonths = numMonths === 1 ? 4 : 0;
const payPeriodFeatureFlagEnabled = useFeatureFlag('payPeriodsEnabled');
const [payPeriodViewEnabled, setPayPeriodViewEnabled] =
useSyncedPref('showPayPeriods');

return (
<View
Expand All @@ -29,6 +35,22 @@ export const BudgetPageHeader = memo<BudgetPageHeaderProps>(
flexShrink: 0,
}}
>
{payPeriodFeatureFlagEnabled && (
<View style={{ alignItems: 'center', marginBottom: 5 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={String(payPeriodViewEnabled) === 'true'}
onChange={e =>
setPayPeriodViewEnabled(e.target.checked ? 'true' : 'false')
}
/>
<span>
<Trans>Show pay periods</Trans>
</span>
</label>
</View>
)}
<View
style={{
marginRight: 5 + getScrollbarWidth() - offsetMultipleMonths,
Expand Down
21 changes: 17 additions & 4 deletions packages/desktop-client/src/components/budget/MonthPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ export const MonthPicker = ({
const [hoverId, setHoverId] = useState(null);
const [targetMonthCount, setTargetMonthCount] = useState(12);

const currentMonth = monthUtils.currentMonth();
// Don't capture currentMonth during render - calculate it when needed
// This ensures pay period config is loaded before determining current month
const getCurrentMonth = () => monthUtils.currentMonth();
const currentMonth = getCurrentMonth();
const firstSelectedMonth = startMonth;

const lastSelectedMonth = monthUtils.addMonths(
Expand Down Expand Up @@ -93,7 +96,7 @@ export const MonthPicker = ({
<Link
variant="button"
buttonVariant="bare"
onPress={() => onSelect(currentMonth)}
onPress={() => onSelect(getCurrentMonth())}
style={{
padding: '3px 3px',
marginRight: '12px',
Expand Down Expand Up @@ -127,7 +130,11 @@ export const MonthPicker = ({
</View>
</Link>
{range.map((month, idx) => {
const monthName = monthUtils.format(month, 'MMM', locale);
const displayLabel = monthUtils.getMonthDisplayName(
month,
undefined,
locale,
);
const selected =
idx >= firstSelectedIndex && idx <= lastSelectedIndex;

Expand Down Expand Up @@ -214,7 +221,13 @@ export const MonthPicker = ({
onMouseLeave={() => setHoverId(null)}
>
<View>
{size === 'small' ? monthName[0] : monthName}
{monthUtils.isPayPeriod(month)
? size === 'small'
? `P${String(parseInt(month.slice(5, 7)) - 12)}`
: displayLabel
: size === 'small'
? displayLabel[0]
: displayLabel}
{showYearHeader && (
<View
style={{
Expand Down
Loading