whisper-money/resources/js/lib/chart-calculations.ts

230 lines
6.1 KiB
TypeScript

import { AccountType } from '@/types/account';
/**
* Account types that reduce net worth (liabilities).
*
* A credit card is deliberately NOT a liability: its balance is spendable
* credit the user treats as an asset, so it adds to net worth like a regular
* account. Only loans (debt that must be repaid) subtract.
*/
export const LIABILITY_TYPES: AccountType[] = ['loan'];
/**
* Check if an account type is a liability
*/
export function isLiabilityType(type: AccountType): boolean {
return LIABILITY_TYPES.includes(type);
}
/**
* Get the sign multiplier for an account type
* Assets are positive (+1), Liabilities are negative (-1)
*/
export function getAccountSign(type: AccountType): 1 | -1 {
return isLiabilityType(type) ? -1 : 1;
}
/**
* Signed contribution of a raw account balance to net worth.
*
* Liabilities are stored as positive magnitudes, so they always subtract.
* Assets keep their real sign, so a genuinely negative asset balance (e.g. an
* overdrawn checking account) correctly reduces net worth instead of being
* flipped positive and added.
*/
export function netWorthContribution(
type: AccountType,
balance: number,
): number {
return isLiabilityType(type) ? -Math.abs(balance) : balance;
}
export interface NetWorthBarScaling {
/**
* Factor applied to each asset segment so the stacked (upward) bar height
* equals net worth. `0` when net worth is negative — assets are hidden and
* the deficit is drawn instead.
*/
scaleFactor: number;
/**
* Signed net worth to draw as a single downward bar when it is negative,
* or `null` when net worth is zero/positive (assets carry the height).
*/
deficit: number | null;
}
/**
* Decide how a period renders in the net worth bar chart.
*
* Positive net worth stacks assets upward, scaled so the total height equals
* net worth. Negative net worth can't be shown as a stack of positive
* segments, so assets are hidden (scaleFactor 0) and the deficit is drawn as a
* single downward bar from the zero baseline.
*/
export function computeNetWorthBarScaling(
totalAssets: number,
totalLiabilities: number,
hasLiabilities: boolean,
): NetWorthBarScaling {
if (!hasLiabilities) {
return { scaleFactor: 1, deficit: null };
}
const netWorth = totalAssets - totalLiabilities;
if (netWorth < 0) {
return { scaleFactor: 0, deficit: netWorth };
}
return {
scaleFactor: totalAssets > 0 ? netWorth / totalAssets : 1,
deficit: null,
};
}
/**
* Data point representing net worth for a month
*/
export interface MonthDataPoint {
month: string;
timestamp?: number;
value: number;
}
/**
* Data point with percentage change
*/
export interface PercentDataPoint extends MonthDataPoint {
percent: number | null;
}
/**
* Data point with absolute change
*/
export interface ChangeDataPoint extends MonthDataPoint {
change: number | null;
}
/**
* Account info for calculations
*/
export interface AccountInfo {
id: string;
type: AccountType;
currency_code: string;
}
export interface NetWorthSeriesOptions {
includeLoanAccounts?: boolean;
includeRealEstateAccounts?: boolean;
}
/**
* Compute net worth series from raw balance data
* Handles sign correctly: assets add to NW, liabilities subtract
*/
export function computeNetWorthSeries(
data: Array<Record<string, string | number>>,
accounts: Record<string, AccountInfo>,
options: NetWorthSeriesOptions = {},
): MonthDataPoint[] {
if (!data || data.length === 0) {
return [];
}
const includeLoanAccounts = options.includeLoanAccounts ?? true;
const includeRealEstateAccounts = options.includeRealEstateAccounts ?? true;
const accountIds = Object.entries(accounts)
.filter(([, account]) => includeLoanAccounts || account.type !== 'loan')
.filter(
([, account]) =>
includeRealEstateAccounts || account.type !== 'real_estate',
)
.map(([id]) => id);
return data.map((point) => {
const month = point.month as string;
const timestamp = point.timestamp as number | undefined;
const totalNetWorth = accountIds.reduce((sum, id) => {
const balance = point[id];
if (typeof balance !== 'number') return sum;
const account = accounts[id];
if (!account) return sum + balance;
const sign = getAccountSign(account.type);
const normalizedBalance = sign === -1 ? Math.abs(balance) : balance;
return sum + sign * normalizedBalance;
}, 0);
return {
month,
timestamp,
value: totalNetWorth,
};
});
}
/**
* Compute delta series (change from previous month)
*/
export function computeDeltaSeries(
nwSeries: MonthDataPoint[],
): ChangeDataPoint[] {
return nwSeries.map((point, index) => {
if (index === 0) {
return { ...point, change: null };
}
const previousValue = nwSeries[index - 1].value;
return {
...point,
change: point.value - previousValue,
};
});
}
/**
* Compute month-over-month percentage change
*/
export function computeMoMPercent(
nwSeries: MonthDataPoint[],
): PercentDataPoint[] {
return nwSeries.map((point, index) => {
if (index === 0) {
return { ...point, percent: null };
}
const previousValue = nwSeries[index - 1].value;
// Guard against division by zero or undefined previous value
if (previousValue === 0) {
return { ...point, percent: null };
}
const percentChange =
((point.value - previousValue) / Math.abs(previousValue)) * 100;
return { ...point, percent: percentChange };
});
}
/**
* Format percentage value for display
*/
export function formatPercentValue(
value: number | null,
options?: { decimals?: number },
): string {
if (value === null) return '—';
const decimals = options?.decimals ?? 1;
const formatted = value.toFixed(decimals);
const sign = value > 0 ? '+' : '';
return `${sign}${formatted}%`;
}