mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-28 10:33:02 -05:00
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:
@@ -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}>
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
6
upcoming-release-notes/6383.md
Normal file
6
upcoming-release-notes/6383.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [sjones512]
|
||||
---
|
||||
|
||||
Fix bugs with different Live date ranges on report header
|
||||
Reference in New Issue
Block a user