|
| 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