Skip to main content

Goal Allocation Setting Plan

Overview

Hybrid approach: both value-based and percentage-based allocation to prevent double-counting while properly attributing growth across multiple goals.

Key Features:

  • Time-aware unallocated balance calculation
  • Date range overlap detection for percentage calculations
  • Historical value tracking for accurate attribution

Allocation Approach

New Goal Initialization

When a goal is created, allocations are initialized with:

  • allocationAmount: 0 for all accounts
  • allocationPercentage: 0 for all accounts
  • allocationDate: goal.startDate (not creation date)

This allows users to allocate values later, with the allocation date anchored to the goal's start period.

Initial Allocation

  • User specifies an explicit amount to allocate from unallocated account balance
  • User also specifies an allocation percentage for this goal
  • Allocation percentage = allocated_amount / account_value_at_allocation_date
  • Init valuation is locked in (immutable)

Example

  • Account A balance at goal start date: $1000
  • Goal 1: allocated $500
  • Goal 2: allocated $300
  • Unallocated: $200 (available for new goals)

Growth Calculation

Fixed init valuation, proportional growth:

  • Goal init value = $500 (locked, never changes)
  • Allocation ratio = $500 / $1000 = 50%
  • Account grows to $1100 (+$100 gain)
  • Goal actual gain = 50% × $100 = $50
  • Goal current value = $500 + $50 = $550

Growth is attributed only to the allocation percentage, not the entire account movement.

Rules and Constraints

Active/Archived Goals

  • Remain completely untouched by other goal operations
  • No value reshuffling between active goals
  • Each goal maintains independent allocation and valuation tracking

Goal Deletion (Removal)

  • Goal A deleted → its current value becomes free/unallocated
  • This unallocated balance is now available for new goal allocations
  • No cascading impact on other active goals

Reallocation to New Goal

  • User can only allocate from unallocated balance
  • Cannot take value from other active/archived goals
  • New allocation uses current value as init valuation

Allocation Editing

Edit Amount (Init Valuation)

  • User updates allocated amount (e.g., $500 → $600)
  • Goal's init valuation recalculates to new amount
  • Changes the baseline going forward
  • Historical growth already locked in (no retroactive changes)
  • Freed/required balance impacts unallocated pool

Example:

  • Current allocation: $500
  • User edits to $600 → init valuation becomes $600
  • Unallocated pool decreases by $100

Edit Percentage (Allocation Strategy)

  • User edits percentage for a specific historical period only
  • Init amount stays locked (never changes)
  • Only the growth calculation for that period recalculates with new %
  • Other periods and current value update accordingly
  • No impact to periods before or after the edited period

Example:

  • Account A: $1000 initial
  • Goal A: $500 allocated @ 50%
  • Account A grows: $1000 → $1100 (+$100)
  • Before edit:
    • Growth = $100 × 50% = $50
    • Goal value = $500 + $50 = $550
  • User edits: 50% → 70%
    • Init amount: $500 ✓ (unchanged)
    • Growth recalculated: $100 × 70% = $70
    • Goal value: $500 + $70 = $570
    • Impact: +$20 additional gain (only in this period)

Allocation History & Versioning

Track allocation changes:

Allocation History:
- Version 1: Amount=$500, Percentage=50%, Date=Jan 1, Status=Active
- Version 2: Amount=$600, Percentage=50%, Date=Feb 15, Status=Active
- Version 3: Amount=$600, Percentage=60%, Date=Apr 1, Status=Active

Each version stores:

  • Amount
  • Percentage
  • Start date (when this version became active)
  • End date (when next version started, or null if current)

Growth Calculation with History

Segmented growth:

  1. For each allocation period (between version changes), calculate:
    • period_gain = (account_value_end - account_value_start) × allocation_percentage
  2. Sum all period gains for total actual gain
  3. Current value = init_amount + total_actual_gain

Unallocated balance growth:

  • Unallocated percentage = 100% - sum(all_goal_percentages)
  • Unallocated balance grows with its own allocation percentage
  • When user creates new goal later, they allocate from the grown unallocated balance

Example Timeline:

Initial State:
- Account A: $1000
- Goal 1: $500 @ 50%
- Goal 2: $300 @ 30%
- Unallocated: $200 @ 20%

After Account A grows to $1100 (+$100):
- Goal 1 value: $500 + ($100 × 50%) = $550
- Goal 2 value: $300 + ($100 × 30%) = $330
- Unallocated: $200 + ($100 × 20%) = $220 ← also grows!

User creates Goal 3 (allocates from unallocated):
- Goal 3 init amount: $220 (the grown unallocated balance)
- Goal 3 percentage: 20%
- From now on: Goal 3 grows with 20% of future Account A growth

Validation always holds:

  • sum(goal_values) + unallocated_value = account_current_value

Example with segmented periods:

  • Period 1 (Jan 1 - Feb 15): Growth $100 @ 50% = $50
  • Period 2 (Feb 15 - Apr 1): Growth $150 @ 50% = $75
  • Period 3 (Apr 1 - Now): Growth $80 @ 60% = $48
  • Total gain = $173
  • Current value = $600 + $173 = $773

Validation Logic

sum(all_goal_allocations) + unallocated_balance = account_total_value

Before allocation:

  • User selects amount X from unallocated balance
  • Validate: X <= unallocated_balance
  • Calculate allocation ratio: X / account_value_at_allocation_date
  • Store ratio for future growth calculations

Implementation Requirements

  1. Track allocation history per account-goal pair

    • Amount allocated
    • Allocation ratio
    • Allocation date
    • Status (active/archived)
  2. Calculate actual gain from growth

    • Current account value
    • Previous account value (at allocation date)
    • Gain = (current - previous) × allocation_ratio
  3. Maintain unallocated pool

    • Real-time calculation: account_value - sum(all_allocations)
    • UI validation before allocation
  4. Handle goal deletion

    • When goal deleted, mark as deleted
    • Add current value to unallocated pool
    • Don't touch other goals' allocations

Edge Cases

  • Account value fluctuations: Percentage-based growth calculation handles naturally
  • Multiple allocations same account: Each goal gets proportional share of growth
  • Account value < sum of allocations: Should be prevented by validation
  • Reallocating between goals: Only from unallocated pool, not between active goals
  • Sequential goals (no overlap): Goals that don't overlap in time don't affect each other's percentage availability
  • Goal start date change: Resets all allocations for that goal (requires user confirmation)
  • Goal end date change: Updates all allocations' end dates to maintain overlap logic

Time-Aware Allocation Logic

When managing multiple financial goals that share the same investment accounts, the system needs to accurately track:

  1. Unallocated Balance: How much of an account's value is available for a new goal
  2. Unallocated Percentage: What percentage of future growth is available for allocation

The challenge arises when goals have different start dates and end dates. A simple sum of allocated percentages doesn't account for:

  • Goals that have already ended before the new goal starts
  • Goals that haven't started yet when calculating available balance
  • The actual "contributed value" of each allocation at different points in time

Core Concepts

Contributed Value

The Contributed Value of an allocation at any point in time is:

ContributedValue = InitialContribution + (AccountGrowth × AllocationPercent)

Where:

  • InitialContribution = The fixed dollar amount allocated when the goal started
  • AccountGrowth = AccountValue(QueryDate) - AccountValue(AllocationStartDate)
  • AllocationPercent = The percentage of growth this goal is entitled to (0-100%)

Time-Aware Unallocated Balance

The Unallocated Balance for a goal at its start date is:

UnallocatedBalance = AccountValueAtGoalStart - Σ(OtherAllocations' ContributedValues)

This is calculated by:

  1. Getting the account value at the current goal's start date
  2. For each other goal's allocation on this account:
    • Calculate its contributed value UP TO the current goal's start date
  3. Subtract the sum of contributed values from the account value

Date Range Overlap

For percentage calculations, allocations are only counted if their time periods overlap with the current goal. Two date ranges overlap if:

A.startDate < B.endDate AND A.endDate > B.startDate

This means:

  • Goals that end before the current goal starts → Not counted
  • Goals that start after the current goal ends → Not counted
  • Goals that partially or fully overlap → Counted

Utility Functions

Located in src/pages/goals/lib/goal-utils.ts:

doDateRangesOverlap()

Checks if two date ranges overlap.

export function doDateRangesOverlap(
startA: string | Date | undefined,
endA: string | Date | undefined,
startB: string | Date | undefined,
endB: string | Date | undefined,
): boolean {
if (!startA || !endA || !startB || !endB) {
return false; // Conservative: if dates missing, assume no overlap
}

const dateStartA = new Date(startA);
const dateEndA = new Date(endA);
const dateStartB = new Date(startB);
const dateEndB = new Date(endB);

return dateStartA < dateEndB && dateEndA > dateStartB;
}

calculateAllocationContributedValue()

Calculates the contributed value of an allocation at a specific date.

export function calculateAllocationContributedValue(
initialContribution: number,
allocationPercentage: number,
accountValueAtAllocationStart: number,
accountValueAtQueryDate: number,
allocationStartDate: Date,
queryDate: Date
): number {
// If query date is before allocation started, no contribution yet
if (queryDate < allocationStartDate) {
return 0;
}

// Calculate account growth since allocation start
const accountGrowth = Math.max(0, accountValueAtQueryDate - accountValueAtAllocationStart);

// Allocated portion of growth
const allocatedGrowth = accountGrowth * (allocationPercentage / 100);

// Total contributed value = initial + growth
return initialContribution + allocatedGrowth;
}

calculateUnallocatedBalance()

Calculates the remaining unallocated balance.

export function calculateUnallocatedBalance(
accountValueAtQueryDate: number,
otherAllocationsContributedValues: number[]
): number {
const totalContributed = otherAllocationsContributedValues.reduce((sum, v) => sum + v, 0);
return Math.max(0, accountValueAtQueryDate - totalContributed);
}

Component Implementation

Edit Allocations Modal

Located in src/pages/goals/components/edit-allocations-modal.tsx:

Historical Values Fetching:

The modal fetches account valuations at multiple dates:

  1. Current goal's start date - For calculating the base value
  2. Each other allocation's start date - For calculating their contributed values
// Fetch requests for historical values
const fetchRequests: { accountId: string; date: string }[] = [];

// 1. Current goal's start date (for all accounts)
for (const account of accounts) {
fetchRequests.push({ accountId: account.id, date: currentGoalStartDate });
}

// 2. Other allocations' start dates
for (const alloc of allAllocations) {
if (alloc.goalId === goal.id) continue; // Skip current goal
const allocStartDate = alloc.allocationDate || alloc.startDate;
fetchRequests.push({ accountId: alloc.accountId, date: allocStartDate });
}

Unallocated Balance Calculation:

const calculateAvailableBalances = () => {
for (const account of accounts) {
// Get account value at current goal's start date
let accountValueAtGoalStart: number;
if (isPastGoal) {
accountValueAtGoalStart = historicalValuesCache[getCacheKey(account.id, currentGoalStartDate)] ?? 0;
} else {
accountValueAtGoalStart = currentAccountValues.get(account.id) || 0;
}

// Calculate contributed values from OTHER goals' allocations
const contributedValues: number[] = [];
for (const alloc of allAllocations) {
if (alloc.goalId === goal.id) continue;
if (alloc.accountId !== account.id) continue;

const contributedValue = calculateAllocationContributedValue(
alloc.initialContribution || 0,
alloc.allocatedPercent || 0,
accountValueAtAllocStart,
accountValueAtGoalStart,
allocStartDateObj,
goalStartDateObj
);
contributedValues.push(contributedValue);
}

// Unallocated balance = Account value - Sum of contributed values
balances[account.id] = calculateUnallocatedBalance(accountValueAtGoalStart, contributedValues);
}
};

Time-Aware Percentage Calculation:

const otherGoalsPercent = allAllocations.reduce((sum, existingAlloc) => {
if (existingAlloc.accountId !== account.id) return sum;
if (existingAlloc.goalId === goal.id) return sum;

// Only count allocations that OVERLAP with current goal's time period
const overlaps = doDateRangesOverlap(
goal.startDate, // Current goal start
goal.dueDate, // Current goal end
existingAlloc.startDate, // Other allocation start
existingAlloc.endDate // Other allocation end
);

if (!overlaps) return sum;

return sum + (existingAlloc.allocatedPercent || 0);
}, 0);

const remainingUnallocatedPercent = Math.max(0, 100 - otherGoalsPercent - currentInputPercent);

Examples

Example 1: Sequential Goals (No Overlap)

GoalStart DateEnd DateAccount AInitialAlloc %
Goal 12025-01-012030-12-31$100,000$50,000100%
Goal 22031-01-012035-12-31???

Account A value at 2031-01-01: $200,000 (grew from $100,000)

Calculating Goal 2's unallocated balance:

  1. Goal 1's contributed value at 2031-01-01:

    • Initial: $50,000
    • Growth: ($200,000 - $100,000) × 100% = $100,000
    • Contributed: $150,000
  2. Unallocated Balance: $200,000 - $150,000 = $50,000

  3. Unallocated Percentage:

    • Goal 1 period (2025-2030) does NOT overlap with Goal 2 (2031-2035)
    • Goal 1's 100% is not counted
    • Unallocated: 100%

Example 2: Overlapping Goals

GoalStart DateEnd DateAccount AInitialAlloc %
Goal 12025-01-012030-12-31$100,000$50,00050%
Goal 22028-01-012032-12-31???

Account A value at 2028-01-01: $150,000

Calculating Goal 2's unallocated balance:

  1. Goal 1's contributed value at 2028-01-01:

    • Initial: $50,000
    • Growth: ($150,000 - $100,000) × 50% = $25,000
    • Contributed: $75,000
  2. Unallocated Balance: $150,000 - $75,000 = $75,000

  3. Unallocated Percentage:

    • Goal 1 (2025-2030) OVERLAPS with Goal 2 (2028-2032)
    • Goal 1's 50% IS counted
    • Unallocated: 50%

Example 3: Future Goal

GoalStart DateEnd DateAccount A (Current)
Goal 12025-01-012030-12-31$100,000 → $150,000
Goal 32026-01-012028-12-31?

Today: 2024-12-24 (Goal 3 is in the future)

Calculating Goal 3's unallocated balance:

  1. Since Goal 3 is in the future, use current account value: $150,000
  2. Goal 1's contributed value at current time:
    • Initial: $50,000
    • Growth: ($150,000 - $100,000) × 50% = $25,000
    • Contributed: $75,000
  3. Unallocated Balance: $150,000 - $75,000 = $75,000

Backend Date Handling

Allocation Dates in Database

The goals_allocation table stores:

  • allocation_date: Legacy field (often NULL)
  • start_date: Backfilled from goal's start_date
  • end_date: Backfilled from goal's due_date

Start Date Change Behavior

When a goal's start_date is changed:

  1. All allocations for that goal are reset (values set to 0)
  2. User is shown a confirmation dialog before proceeding
  3. This is handled in goals_service.rs
// When start_date changes, reset all allocations
if new_goal.start_date != existing_goal.start_date {
self.repository.reset_allocations_for_goal(&goal.id).await?;
}

End Date Change Behavior

When a goal's due_date is changed:

  1. All allocations' end_date is updated to match
  2. This maintains the time-aware overlap logic
// When due_date changes, update allocations' end_date
if new_goal.due_date != existing_goal.due_date {
self.repository.update_allocations_end_date(&goal.id, &new_goal.due_date).await?;
}

Frontend Components

EditAllocationsModal

Location: src/pages/goals/components/edit-allocations-modal.tsx

Props:

  • goal: Goal - Current goal (includes startDate, dueDate)
  • allAllocations: GoalAllocation[] - All allocations across all goals
  • currentAccountValues: Map<string, number> - Current account values

Features:

  • Fetches historical valuations at multiple dates
  • Calculates time-aware unallocated balance
  • Shows dynamic update as user types
  • Validates against unallocated balance

EditSingleAllocationModal

Location: src/pages/goals/components/edit-single-allocation-modal.tsx

Props:

  • goal: { id, title, startDate, dueDate } - Goal info
  • allAllocations: GoalAllocation[] - All allocations
  • currentAccountValue: number - Current account value

Features:

  • Same time-aware logic as multi-allocation modal
  • Single account focus
  • Real-time unallocated balance display

Completed Goals

When a goal is marked as completed (isAchieved: true), special behavior is applied:

Allocation Release

Completed goals' allocations are NOT counted in unallocated calculations for other goals:

// In allocation calculations:
if (isGoalAchieved(alloc.goalId)) {
return sum; // Skip - allocations are released
}

This means:

  • Percentage Released: Other goals see the full percentage available (e.g., if Goal 1 had 100%, new goals see 100% available)
  • Balance Available: The contributed value is still tracked historically, but not subtracted from available balance for new goals

Historical Records

Completed goals preserve their historical data:

  • Chart: Shows the final achievement record (read-only)
  • Allocation History: Displays the allocations as they were at completion
  • No Editing: Edit/delete buttons are hidden for completed goals

UI Changes

When viewing a completed goal's detail page:

ElementActive GoalCompleted Goal
HeaderNormalShows ✅ "Completed" badge
DescriptionNormal"Goal achieved. Allocations released."
"Edit Allocations" buttonVisibleHidden
Allocations Section"Current Allocations" with edit actions"Allocation History" (read-only)
Info Message-Green box: "Allocations have been released..."

Technical Implementation

Files Modified:

  • edit-allocations-modal.tsx - Skip completed goals in calculations
  • edit-single-allocation-modal.tsx - Skip completed goals in calculations
  • goal-details-page.tsx - Conditional UI for completed goals
  • allocation-history-table.tsx - Pass allGoals prop

New Props:

  • allGoals: Goal[] - Passed to allocation modals/tables to check isAchieved status