Skip to content

Commit 5e3fd04

Browse files
committed
✨ feat: improve ByDate target progress calculation and preview
1 parent fd5ba0f commit 5e3fd04

4 files changed

Lines changed: 653 additions & 24 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
# Handoff: ByDate Target Progress - Implementation Complete
2+
3+
## Summary
4+
5+
Fixed the ByDate target progress calculation and added a preview feature. The progress now correctly tracks actual payments, and shows a preview of potential progress if budgeted amounts are paid.
6+
7+
## Changes Made
8+
9+
### 1. New Method: `calculate_cumulative_budgeted`
10+
11+
**File:** `src/services/budget.rs:414-428`
12+
13+
Calculates the sum of all budgeted amounts for a category across all periods up to a target date.
14+
15+
```rust
16+
pub fn calculate_cumulative_budgeted(
17+
&self,
18+
category_id: CategoryId,
19+
up_to_period: &BudgetPeriod,
20+
) -> EnvelopeResult<Money>
21+
```
22+
23+
### 2. New Method: `calculate_cumulative_paid`
24+
25+
**File:** `src/services/budget.rs:436-464`
26+
27+
Calculates the sum of all payments (negative activity) for a category across all time up to a target date. Returns the absolute value of outflows.
28+
29+
```rust
30+
pub fn calculate_cumulative_paid(
31+
&self,
32+
category_id: CategoryId,
33+
up_to_period: &BudgetPeriod,
34+
) -> EnvelopeResult<Money>
35+
```
36+
37+
### 3. Updated TUI Progress Display
38+
39+
**File:** `src/tui/views/budget.rs:223-275`
40+
41+
**Progress Logic:**
42+
- **Paid is the source of truth** - If any payments exist, use cumulative paid
43+
- **Budgeted is fallback only** - Only used when no payments have been made yet
44+
45+
**Preview Feature:**
46+
- Shows potential progress if all budgeted money is paid
47+
- Only displays unpaid budgeted amount (avoids double-counting)
48+
- Format: `$2000 by Dec 2026 (5%10%)` where:
49+
- `5%` (magenta) = actual progress from payments
50+
- `→ 10%` (white) = preview if budgeted amount is also paid
51+
- Arrow only appears when preview differs from progress by more than 0.5%
52+
53+
**Key Formula:**
54+
```rust
55+
// Progress: paid wins, budgeted is fallback
56+
let progress_amount = if cumulative_paid.cents() > 0 {
57+
cumulative_paid.cents()
58+
} else {
59+
cumulative_budgeted.cents().max(0)
60+
};
61+
62+
// Preview: only add unpaid portion of budgeted
63+
let unpaid_budgeted = (cumulative_budgeted.cents() - cumulative_paid.cents()).max(0);
64+
let preview_amount = cumulative_paid.cents() + unpaid_budgeted;
65+
```
66+
67+
### 4. New Imports
68+
69+
**File:** `src/tui/views/budget.rs:5,14`
70+
71+
```rust
72+
use chrono::Datelike;
73+
use crate::models::{AccountType, BudgetPeriod, TargetCadence};
74+
```
75+
76+
### 5. New Tests
77+
78+
**File:** `src/services/budget.rs`
79+
80+
- `test_cumulative_budgeted_for_bydate_progress` (line 1033) - Tests cumulative budgeted calculation
81+
- `test_cumulative_paid_for_bydate_progress` (line 1065) - Tests cumulative paid calculation
82+
- `test_paid_wins_over_budgeted` (line 1117) - Verifies paid always wins when payments exist
83+
84+
## Behavior Examples
85+
86+
| Scenario | Budgeted | Paid | Progress | Preview |
87+
|----------|----------|------|----------|---------|
88+
| No activity | $0 | $0 | 0% | (none) |
89+
| Budgeted only | $200 | $0 | 10% | (none) |
90+
| Paid only | $0 | $100 | 5% | (none) |
91+
| Paid = Budgeted | $100 | $100 | 5% | (none) |
92+
| Paid < Budgeted | $200 | $100 | 5% | → 10% |
93+
| Paid > Budgeted | $100 | $200 | 10% | (none) |
94+
95+
*Assuming $2000 target*
96+
97+
## Files Modified
98+
99+
1. `src/services/budget.rs` - Added two new methods and three tests
100+
2. `src/tui/views/budget.rs` - Updated progress display with styled spans
101+
102+
## Test Results
103+
104+
- All 345 tests pass
105+
- Release build compiles successfully
106+
107+
## Key Design Decisions
108+
109+
1. **Paid wins over budgeted** - The actual payment is the source of truth for debt payoff progress, not the intention to pay (budget)
110+
111+
2. **Budgeted as fallback** - Before any payments, budgeted shows planned progress
112+
113+
3. **Preview shows potential** - White preview percentage shows what progress would be if you follow through on your budget
114+
115+
4. **No double-counting** - Preview only adds unpaid budgeted amount, not total budgeted
116+
117+
## Remaining Work
118+
119+
### Fix: Suggested Budget Should Account for Cumulative Paid
120+
121+
**Problem:** The suggested budget calculation for ByDate targets does NOT consider what's already been paid. It divides the full target by months remaining, ignoring progress.
122+
123+
**Current behavior (wrong):**
124+
- Target: $2000 by Dec 2026
125+
- Already paid: $500
126+
- Months remaining: 12
127+
- Suggested: $2000 / 12 = **$167/month** (ignores the $500)
128+
129+
**Expected behavior:**
130+
- Target: $2000 by Dec 2026
131+
- Already paid: $500
132+
- Remaining needed: $1500
133+
- Months remaining: 12
134+
- Suggested: $1500 / 12 = **$125/month**
135+
136+
**Location:** `src/models/target.rs:215-233` - `calculate_by_date_for_period` method
137+
138+
**The issue:** The model layer doesn't have access to storage/services to calculate cumulative paid.
139+
140+
**Solution options:**
141+
142+
1. **Move calculation to BudgetService** (recommended)
143+
- Create new method `get_suggested_budget_for_bydate` in `BudgetService`
144+
- This method can access `calculate_cumulative_paid`
145+
- Formula: `(target_amount - cumulative_paid) / months_remaining`
146+
147+
2. **Pass cumulative_paid into the model method**
148+
- Add optional parameter to `calculate_for_period`
149+
- Less clean but maintains current structure
150+
151+
**Implementation sketch (Option 1):**
152+
153+
```rust
154+
// In src/services/budget.rs
155+
156+
/// Get suggested budget for a ByDate target, accounting for progress
157+
pub fn get_suggested_budget_with_progress(
158+
&self,
159+
category_id: CategoryId,
160+
period: &BudgetPeriod,
161+
) -> EnvelopeResult<Option<Money>> {
162+
let target = match self.storage.targets.get_for_category(category_id)? {
163+
Some(t) => t,
164+
None => return Ok(None),
165+
};
166+
167+
match &target.cadence {
168+
TargetCadence::ByDate { target_date } => {
169+
let target_period = BudgetPeriod::monthly(target_date.year(), target_date.month());
170+
let cumulative_paid = self.calculate_cumulative_paid(category_id, &target_period)?;
171+
172+
let remaining = (target.amount.cents() - cumulative_paid.cents()).max(0);
173+
let months = months_between(period.start_date(), *target_date);
174+
175+
if months <= 0 {
176+
Ok(Some(Money::from_cents(remaining)))
177+
} else {
178+
Ok(Some(Money::from_cents((remaining as f64 / months as f64).ceil() as i64)))
179+
}
180+
}
181+
_ => Ok(Some(target.calculate_for_period(period))),
182+
}
183+
}
184+
```
185+
186+
**Files to modify:**
187+
- `src/services/budget.rs` - Add new method
188+
- Anywhere `get_suggested_budget` is called for ByDate targets - use new method instead
189+
190+
## Manual Test Scenarios
191+
192+
1. Create a category with ByDate target ($2000 by Dec 2026)
193+
2. Budget $200, no payment → Should show 10% (budgeted fallback)
194+
3. Pay $100 with $0 budgeted → Should show 5% (paid, no preview)
195+
4. Pay $100, budget $200 → Should show 5% → 15% (paid + preview of unpaid $200)
196+
5. Pay $200, budget $100 → Should show 10% (paid wins, no preview since budgeted < paid)

0 commit comments

Comments
 (0)