Refactor how crossover report dates are handled (#6383)

* Refactor how report dates are handled to more closely match pattern oin CashFlow report. Fixes issues with black screen when selecting certain Live date modes

* Add null handling for allMonths and make date clamping more consistent.

* Remove arbitray limit for at least two montsh to calculate expenses.  This allows the chart to work (someone) with one previous month of expenses.
This commit is contained in:
scojo
2025-12-16 07:30:30 -08:00
committed by GitHub
parent 10b1fd7dcd
commit 081a3b0ca9
3 changed files with 112 additions and 52 deletions

View File

@@ -14,7 +14,6 @@ import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
import { View } from '@actual-app/components/view';
import * as d from 'date-fns';
import { send } from 'loot-core/platform/client/fetch';
import * as monthUtils from 'loot-core/shared/months';
@@ -38,9 +37,9 @@ import { CategorySelector } from '@desktop-client/components/reports/CategorySel
import { CrossoverGraph } from '@desktop-client/components/reports/graphs/CrossoverGraph';
import { Header } from '@desktop-client/components/reports/Header';
import { LoadingIndicator } from '@desktop-client/components/reports/LoadingIndicator';
import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
import { createCrossoverSpreadsheet } from '@desktop-client/components/reports/spreadsheets/crossover-spreadsheet';
import { useReport } from '@desktop-client/components/reports/useReport';
import { fromDateRepr } from '@desktop-client/components/reports/util';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useFormat } from '@desktop-client/hooks/useFormat';
@@ -73,6 +72,12 @@ type CrossoverData = {
targetNestEgg: number | null;
};
export const defaultTimeFrame = {
start: monthUtils.subMonths(monthUtils.currentMonth(), 120),
end: monthUtils.subMonths(monthUtils.currentMonth(), 1),
mode: 'full',
} satisfies TimeFrame;
export function Crossover() {
const params = useParams();
const { data: widget, isLoading } = useWidget<CrossoverWidget>(
@@ -110,8 +115,8 @@ function CrossoverInner({ widget }: CrossoverInnerProps) {
const [start, setStart] = useState<string>('');
const [end, setEnd] = useState<string>('');
const [mode, setMode] = useState<TimeFrame['mode']>('static');
const [earliestTransactionDate, setEarliestTransactionDate] =
useState<string>('');
const [earliestTransaction, setEarliestTransaction] = useState<string>('');
const [latestTransaction, setLatestTransaction] = useState<string>('');
const [selectedExpenseCategories, setSelectedExpenseCategories] =
useState<Array<CategoryEntity>>(expenseCategories);
@@ -164,49 +169,100 @@ function CrossoverInner({ widget }: CrossoverInnerProps) {
useEffect(() => {
async function run() {
const trans = await send('get-earliest-transaction');
const earliestTransactionData = await send('get-earliest-transaction');
const currentMonth = monthUtils.currentMonth();
const earliestMonth = trans
? monthUtils.monthFromDate(d.parseISO(fromDateRepr(trans.date)))
: currentMonth;
const latestMonth = monthUtils.subMonths(currentMonth, 1);
const previousMonth = monthUtils.subMonths(currentMonth, 1);
// Initialize date range from widget meta or use default range
let startMonth = earliestMonth;
let endMonth = monthUtils.isBefore(startMonth, latestMonth)
? latestMonth
: startMonth;
let timeMode: TimeFrame['mode'] = 'static';
// Set earliest transaction date
const earliestDate = earliestTransactionData
? earliestTransactionData.date
: monthUtils.firstDayOfMonth(previousMonth);
setEarliestTransaction(earliestDate);
if (widget?.meta?.timeFrame?.start && widget?.meta?.timeFrame?.end) {
startMonth = widget.meta.timeFrame.start;
endMonth = widget.meta.timeFrame.end;
timeMode = widget.meta.timeFrame.mode || 'static';
}
// Set latest transaction date, ensuring it doesn't include current month
const latestDate = monthUtils.lastDayOfMonth(previousMonth);
setLatestTransaction(latestDate);
setStart(startMonth);
setEnd(endMonth);
setMode(timeMode);
setEarliestTransactionDate(earliestMonth);
const months = monthUtils
.rangeInclusive(earliestMonth, latestMonth)
const allMonths = monthUtils
.rangeInclusive(earliestDate, latestDate)
.map(month => ({
name: month,
pretty: monthUtils.format(month, 'MMMM, yyyy'),
}))
.reverse();
setAllMonths(months);
setAllMonths(allMonths);
}
run();
}, [widget?.meta?.timeFrame]);
}, []);
useEffect(() => {
if (latestTransaction && allMonths?.length) {
const [initialStart, initialEnd, mode] = calculateTimeRange(
widget?.meta?.timeFrame,
defaultTimeFrame,
latestTransaction,
);
const earliestMonth = allMonths[allMonths.length - 1].name;
const latestMonth = allMonths[0].name;
let start = initialStart;
let end = initialEnd;
const clampMonth = (m: string) => {
if (monthUtils.isBefore(m, earliestMonth)) return earliestMonth;
if (monthUtils.isAfter(m, latestMonth)) return latestMonth;
return m;
};
// For both sliding-window and full modes, ensure end doesn't include current month
if (mode === 'sliding-window') {
// Shift both start and end back one month for sliding-window
start = clampMonth(monthUtils.subMonths(start, 1));
end = clampMonth(monthUtils.subMonths(end, 1));
} else if (mode === 'full') {
start = earliestMonth;
end = latestMonth;
} else {
start = clampMonth(start);
end = clampMonth(end);
}
if (monthUtils.isBefore(end, start)) {
end = start;
}
setStart(start);
setEnd(end);
setMode(mode);
}
}, [latestTransaction, widget?.meta?.timeFrame, allMonths]);
function onChangeDates(start: string, end: string, mode: TimeFrame['mode']) {
if (!allMonths?.length) {
return;
}
const earliestMonth = allMonths[allMonths.length - 1].name;
const latestMonth = allMonths[0].name;
const clampMonth = (m: string) => {
if (monthUtils.isBefore(m, earliestMonth)) return earliestMonth;
if (monthUtils.isAfter(m, latestMonth)) return latestMonth;
return m;
};
// For both sliding-window and full modes, ensure end doesn't include current month
if (mode === 'sliding-window') {
// This is because we don't include the current month in the sliding window
start = monthUtils.subMonths(start, 1);
end = monthUtils.subMonths(end, 1);
// Shift both start and end back one month for sliding-window
start = clampMonth(monthUtils.subMonths(start, 1));
end = clampMonth(monthUtils.subMonths(end, 1));
} else if (mode === 'full') {
start = earliestMonth;
end = latestMonth;
} else {
start = clampMonth(start);
end = clampMonth(end);
}
if (monthUtils.isBefore(end, start)) {
end = start;
}
setStart(start);
setEnd(end);
@@ -374,13 +430,13 @@ function CrossoverInner({ widget }: CrossoverInnerProps) {
end={end}
mode={mode}
allMonths={allMonths || []}
earliestTransaction={earliestTransactionDate}
earliestTransaction={earliestTransaction}
latestTransaction={latestTransaction}
onChangeDates={onChangeDates}
conditionsOp="and"
onUpdateFilter={() => {}}
onDeleteFilter={() => {}}
onConditionsOpChange={() => {}}
latestTransaction=""
>
{widget && (
<Button variant="primary" onPress={onSaveWidget}>

View File

@@ -312,26 +312,24 @@ function recalculate(
let expenseIntercept = lastExpense;
let hampelFilteredExpense = 0;
if (expenseMap.size >= 2) {
const y: number[] = months.map(m => expenseMap.get(m) || 0);
const y: number[] = months.map(m => expenseMap.get(m) || 0);
if (params.projectionType === 'trend') {
// Linear trend calculation: y = a + b * t
const x: number[] = months.map((_m, i) => i);
const n = x.length;
const sumX = x.reduce((a, b) => a + b, 0);
const sumY = y.reduce((a, b) => a + b, 0);
const sumXY = x.reduce((a, xi, idx) => a + xi * y[idx], 0);
const sumX2 = x.reduce((a, xi) => a + xi * xi, 0);
const denom = n * sumX2 - sumX * sumX;
if (denom !== 0) {
expenseSlope = (n * sumXY - sumX * sumY) / denom;
expenseIntercept = (sumY - expenseSlope * sumX) / n;
}
} else if (params.projectionType === 'hampel') {
// Hampel filtered median calculation
hampelFilteredExpense = calculateHampelFilteredMedian(y);
if (params.projectionType === 'trend') {
// Linear trend calculation: y = a + b * t
const x: number[] = months.map((_m, i) => i);
const n = x.length;
const sumX = x.reduce((a, b) => a + b, 0);
const sumY = y.reduce((a, b) => a + b, 0);
const sumXY = x.reduce((a, xi, idx) => a + xi * y[idx], 0);
const sumX2 = x.reduce((a, xi) => a + xi * xi, 0);
const denom = n * sumX2 - sumX * sumX;
if (denom !== 0) {
expenseSlope = (n * sumXY - sumX * sumY) / denom;
expenseIntercept = (sumY - expenseSlope * sumX) / n;
}
} else if (params.projectionType === 'hampel') {
// Hampel filtered median calculation
hampelFilteredExpense = calculateHampelFilteredMedian(y);
}
for (let i = 1; i <= maxProjectionMonths; i++) {

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [sjones512]
---
Fix bugs with different Live date ranges on report header