feat(reports): extend report end date to the latest transaction date (#5753)

* add get-latest-transaction function to extend end dates beyond today

* add release notes

* yarn lint

* fix dateEnd and lint warnings

* change getFullRange to return all months

* Update upcoming-release-notes/5753.md

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
This commit is contained in:
Çağdaş Şenel
2025-09-21 14:28:08 +02:00
committed by GitHub
parent 5f5457b226
commit 6b99497d5d
14 changed files with 215 additions and 75 deletions

View File

@@ -34,6 +34,7 @@ type HeaderProps = {
show1Month?: boolean;
allMonths: Array<{ name: string; pretty: string }>;
earliestTransaction: string;
latestTransaction: string;
firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'];
onChangeDates: (
start: TimeFrame['start'],
@@ -59,6 +60,7 @@ export function Header({
show1Month,
allMonths,
earliestTransaction,
latestTransaction,
firstDayOfWeekIdx,
onChangeDates,
filters,
@@ -129,6 +131,7 @@ export function Header({
onChangeDates(
...validateStart(
allMonths[allMonths.length - 1].name,
allMonths[0].name,
newValue,
end,
),
@@ -144,6 +147,7 @@ export function Header({
onChangeDates(
...validateEnd(
allMonths[allMonths.length - 1].name,
allMonths[0].name,
start,
newValue,
),
@@ -191,6 +195,7 @@ export function Header({
...getLiveRange(
'Year to date',
earliestTransaction,
latestTransaction,
true,
firstDayOfWeekIdx,
),
@@ -209,6 +214,7 @@ export function Header({
...getLiveRange(
'Last year',
earliestTransaction,
latestTransaction,
false,
firstDayOfWeekIdx,
),
@@ -227,6 +233,7 @@ export function Header({
...getLiveRange(
'Prior year to date',
earliestTransaction,
latestTransaction,
false,
firstDayOfWeekIdx,
),
@@ -241,7 +248,10 @@ export function Header({
variant="bare"
onPress={() =>
onChangeDates(
...getFullRange(allMonths[allMonths.length - 1].name),
...getFullRange(
allMonths[allMonths.length - 1].name,
allMonths[0].name,
),
)
}
>

View File

@@ -63,6 +63,7 @@ type ReportSidebarProps = {
defaultItems: (item: string) => void;
defaultModeItems: (graph: string, item: string) => void;
earliestTransaction: string;
latestTransaction: string;
firstDayOfWeekIdx: SyncedPrefs['firstDayOfWeekIdx'];
isComplexCategoryCondition?: boolean;
};
@@ -93,6 +94,7 @@ export function ReportSidebar({
defaultItems,
defaultModeItems,
earliestTransaction,
latestTransaction,
firstDayOfWeekIdx,
isComplexCategoryCondition = false,
}: ReportSidebarProps) {
@@ -110,6 +112,7 @@ export function ReportSidebar({
...getLiveRange(
cond,
earliestTransaction,
latestTransaction,
customReportItems.includeCurrentInterval,
firstDayOfWeekIdx,
),
@@ -545,6 +548,7 @@ export function ReportSidebar({
onChangeDates(
...validateStart(
earliestTransaction,
latestTransaction,
newValue,
customReportItems.endDate,
customReportItems.interval,
@@ -578,6 +582,7 @@ export function ReportSidebar({
onChangeDates(
...validateEnd(
earliestTransaction,
latestTransaction,
customReportItems.startDate,
newValue,
customReportItems.interval,

View File

@@ -8,16 +8,18 @@ import { getSpecificRange, validateRange } from './reportRanges';
export function getLiveRange(
cond: string,
earliestTransaction: string,
latestTransaction: string,
includeCurrentInterval: boolean,
firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'],
): [string, string, TimeFrame['mode']] {
let dateStart = earliestTransaction;
let dateEnd = monthUtils.currentDay();
let dateEnd = latestTransaction;
const rangeName = ReportOptions.dateRangeMap.get(cond);
switch (rangeName) {
case 'yearToDate':
[dateStart, dateEnd] = validateRange(
earliestTransaction,
latestTransaction,
monthUtils.getYearStart(monthUtils.currentMonth()) + '-01',
monthUtils.currentDay(),
);
@@ -25,6 +27,7 @@ export function getLiveRange(
case 'lastYear':
[dateStart, dateEnd] = validateRange(
earliestTransaction,
latestTransaction,
monthUtils.getYearStart(
monthUtils.prevYear(monthUtils.currentMonth()),
) + '-01',
@@ -35,6 +38,7 @@ export function getLiveRange(
case 'priorYearToDate':
[dateStart, dateEnd] = validateRange(
earliestTransaction,
latestTransaction,
monthUtils.getYearStart(
monthUtils.prevYear(monthUtils.currentMonth()),
) + '-01',
@@ -43,7 +47,7 @@ export function getLiveRange(
break;
case 'allTime':
dateStart = earliestTransaction;
dateEnd = monthUtils.currentDay();
dateEnd = latestTransaction;
break;
default:
if (typeof rangeName === 'number') {

View File

@@ -4,6 +4,7 @@ import { type SyncedPrefs } from 'loot-core/types/prefs';
export function validateStart(
earliest: string,
latest: string,
start: string,
end: string,
interval?: string,
@@ -35,6 +36,7 @@ export function validateStart(
}
return boundedRange(
earliest,
latest,
dateStart,
interval ? end : monthUtils.monthFromDate(end),
interval,
@@ -44,6 +46,7 @@ export function validateStart(
export function validateEnd(
earliest: string,
latest: string,
start: string,
end: string,
interval?: string,
@@ -75,6 +78,7 @@ export function validateEnd(
}
return boundedRange(
earliest,
latest,
interval ? start : monthUtils.monthFromDate(start),
dateEnd,
interval,
@@ -82,8 +86,12 @@ export function validateEnd(
);
}
export function validateRange(earliest: string, start: string, end: string) {
const latest = monthUtils.currentDay();
export function validateRange(
earliest: string,
latest: string,
start: string,
end: string,
) {
if (end > latest) {
end = latest;
}
@@ -95,12 +103,12 @@ export function validateRange(earliest: string, start: string, end: string) {
function boundedRange(
earliest: string,
latest: string,
start: string,
end: string,
interval?: string,
firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'],
): [string, string, 'static'] {
let latest: string;
switch (interval) {
case 'Daily':
latest = monthUtils.currentDay();
@@ -115,7 +123,6 @@ function boundedRange(
latest = monthUtils.currentDay();
break;
default:
latest = monthUtils.currentMonth();
break;
}
@@ -154,8 +161,7 @@ export function getSpecificRange(
return [dateStart, dateEnd, 'static'];
}
export function getFullRange(start: string) {
const end = monthUtils.currentMonth();
export function getFullRange(start: string, end: string) {
return [start, end, 'full'] as const;
}
@@ -179,7 +185,7 @@ export function calculateTimeRange(
const mode = timeFrame?.mode ?? defaultTimeFrame?.mode ?? 'sliding-window';
if (mode === 'full') {
return getFullRange(start);
return getFullRange(start, end);
}
if (mode === 'sliding-window') {
const offset = monthUtils.differenceInCalendarMonths(end, start);

View File

@@ -260,33 +260,47 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
useEffect(() => {
async function run() {
try {
const trans = await send('get-earliest-transaction');
const currentMonth = monthUtils.currentMonth();
let earliestMonth = trans
? monthUtils.monthFromDate(parseISO(fromDateRepr(trans.date)))
: currentMonth;
const earliestTransaction = await send('get-earliest-transaction');
setEarliestTransaction(
earliestTransaction
? earliestTransaction.date
: monthUtils.currentDay(),
);
// Make sure the month selects are at least populates with a
// year's worth of months. We can undo this when we have fancier
// date selects.
const yearAgo = monthUtils.subMonths(monthUtils.currentMonth(), 12);
if (earliestMonth > yearAgo) {
earliestMonth = yearAgo;
}
const latestTransaction = await send('get-latest-transaction');
setLatestTransaction(
latestTransaction ? latestTransaction.date : monthUtils.currentDay(),
);
const allMonths = monthUtils
.rangeInclusive(earliestMonth, monthUtils.currentMonth())
.map(month => ({
name: month,
pretty: monthUtils.format(month, 'MMMM, yyyy', locale),
}))
.reverse();
const currentMonth = monthUtils.currentMonth();
let earliestMonth = earliestTransaction
? monthUtils.monthFromDate(
parseISO(fromDateRepr(earliestTransaction.date)),
)
: currentMonth;
const latestMonth = latestTransaction
? monthUtils.monthFromDate(
parseISO(fromDateRepr(latestTransaction.date)),
)
: currentMonth;
setAllMonths(allMonths);
} catch (error) {
console.error('Error fetching earliest transaction:', error);
// Make sure the month selects are at least populates with a
// year's worth of months. We can undo this when we have fancier
// date selects.
const yearAgo = monthUtils.subMonths(latestMonth, 12);
if (earliestMonth > yearAgo) {
earliestMonth = yearAgo;
}
const allMonths = monthUtils
.rangeInclusive(earliestMonth, latestMonth)
.map(month => ({
name: month,
pretty: monthUtils.format(month, 'MMMM, yyyy', locale),
}))
.reverse();
setAllMonths(allMonths);
}
run();
}, [locale]);
@@ -477,7 +491,8 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
},
);
const [earliestTransaction, _] = useState('');
const [earliestTransaction, setEarliestTransaction] = useState('');
const [latestTransaction, setLatestTransaction] = useState('');
return (
<Page
@@ -512,6 +527,7 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
start={start}
end={end}
earliestTransaction={earliestTransaction}
latestTransaction={latestTransaction}
firstDayOfWeekIdx={firstDayOfWeekIdx}
mode={mode}
onChangeDates={onChangeDates}

View File

@@ -127,13 +127,27 @@ function CashFlowInner({ widget }: CashFlowInnerProps) {
useEffect(() => {
async function run() {
const trans = await send('get-earliest-transaction');
const earliestMonth = trans
? monthUtils.monthFromDate(d.parseISO(trans.date))
const earliestTransaction = await send('get-earliest-transaction');
setEarliestTransaction(
earliestTransaction
? earliestTransaction.date
: monthUtils.currentDay(),
);
const latestTransaction = await send('get-latest-transaction');
setLatestTransaction(
latestTransaction ? latestTransaction.date : monthUtils.currentDay(),
);
const earliestMonth = earliestTransaction
? monthUtils.monthFromDate(d.parseISO(earliestTransaction.date))
: monthUtils.currentMonth();
const latestMonth = latestTransaction
? monthUtils.monthFromDate(d.parseISO(latestTransaction.date))
: monthUtils.currentMonth();
const allMonths = monthUtils
.rangeInclusive(earliestMonth, monthUtils.currentMonth())
.rangeInclusive(earliestMonth, latestMonth)
.map(month => ({
name: month,
pretty: monthUtils.format(month, 'MMMM, yyyy', locale),
@@ -206,7 +220,8 @@ function CashFlowInner({ widget }: CashFlowInnerProps) {
});
};
const [earliestTransaction, _] = useState('');
const [earliestTransaction, setEarliestTransaction] = useState('');
const [latestTransaction, setLatestTransaction] = useState('');
const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0';
@@ -248,6 +263,7 @@ function CashFlowInner({ widget }: CashFlowInnerProps) {
start={start}
end={end}
earliestTransaction={earliestTransaction}
latestTransaction={latestTransaction}
firstDayOfWeekIdx={firstDayOfWeekIdx}
mode={mode}
show1Month

View File

@@ -270,6 +270,7 @@ function CustomReportInner({ report: initialReport }: CustomReportInnerProps) {
monthUtils.rangeInclusive(startDate, endDate),
);
const [earliestTransaction, setEarliestTransaction] = useState('');
const [latestTransaction, setLatestTransaction] = useState('');
const [report, setReport] = useState(loadReport);
const [savedStatus, setSavedStatus] = useState(
session.savedStatus ?? (initialReport ? 'saved' : 'new'),
@@ -296,26 +297,17 @@ function CustomReportInner({ report: initialReport }: CustomReportInnerProps) {
: monthUtils.currentDay(),
);
const latestTransaction = await send('get-latest-transaction');
setLatestTransaction(
latestTransaction ? latestTransaction.date : monthUtils.currentDay(),
);
const fromDate =
interval === 'Weekly'
? 'dayFromDate'
: (((ReportOptions.intervalMap.get(interval) || 'Day').toLowerCase() +
'FromDate') as 'dayFromDate' | 'monthFromDate' | 'yearFromDate');
const currentDate =
interval === 'Weekly'
? 'currentDay'
: (('current' +
(ReportOptions.intervalMap.get(interval) || 'Day')) as
| 'currentDay'
| 'currentMonth'
| 'currentYear');
const currentInterval =
interval === 'Weekly'
? monthUtils.currentWeek(firstDayOfWeekIdx)
: monthUtils[currentDate]();
const earliestInterval =
interval === 'Weekly'
? monthUtils.weekFromDate(
@@ -334,16 +326,30 @@ function CustomReportInner({ report: initialReport }: CustomReportInnerProps) {
),
);
const latestInterval =
interval === 'Weekly'
? monthUtils.weekFromDate(
d.parseISO(
fromDateRepr(latestTransaction.date || monthUtils.currentDay()),
),
firstDayOfWeekIdx,
)
: monthUtils[fromDate](
d.parseISO(
fromDateRepr(latestTransaction.date || monthUtils.currentDay()),
),
);
const allIntervals =
interval === 'Weekly'
? monthUtils.weekRangeInclusive(
earliestInterval,
currentInterval,
latestInterval,
firstDayOfWeekIdx,
)
: monthUtils[
ReportOptions.intervalRange.get(interval) || 'rangeInclusive'
](earliestInterval, currentInterval);
](earliestInterval, latestInterval);
const allIntervalsMap = allIntervals
.map((inter: string) => ({
@@ -364,6 +370,7 @@ function CustomReportInner({ report: initialReport }: CustomReportInnerProps) {
earliestTransaction
? earliestTransaction.date
: monthUtils.currentDay(),
latestTransaction ? latestTransaction.date : monthUtils.currentDay(),
includeCurrentInterval,
firstDayOfWeekIdx,
);
@@ -804,6 +811,7 @@ function CustomReportInner({ report: initialReport }: CustomReportInnerProps) {
defaultItems={defaultItems}
defaultModeItems={defaultModeItems}
earliestTransaction={earliestTransaction}
latestTransaction={latestTransaction}
firstDayOfWeekIdx={firstDayOfWeekIdx}
isComplexCategoryCondition={isComplexCategoryCondition}
/>

View File

@@ -69,6 +69,7 @@ function CustomReportListCardsInner({
const [nameMenuOpen, setNameMenuOpen] = useState(false);
const [earliestTransaction, setEarliestTransaction] = useState('');
const [latestTransaction, setLatestTransaction] = useState('');
const payees = usePayees();
const accounts = useAccounts();
@@ -85,8 +86,14 @@ function CustomReportListCardsInner({
useEffect(() => {
async function run() {
const trans = await send('get-earliest-transaction');
setEarliestTransaction(trans ? trans.date : monthUtils.currentDay());
const earliestTrans = await send('get-earliest-transaction');
const latestTrans = await send('get-latest-transaction');
setEarliestTransaction(
earliestTrans ? earliestTrans.date : monthUtils.currentDay(),
);
setLatestTransaction(
latestTrans ? latestTrans.date : monthUtils.currentDay(),
);
}
run();
}, []);
@@ -172,6 +179,7 @@ function CustomReportListCardsInner({
accounts={accounts}
categories={categories}
earliestTransaction={earliestTransaction}
latestTransaction={latestTransaction}
firstDayOfWeekIdx={firstDayOfWeekIdx}
showTooltip={!isEditing}
/>

View File

@@ -70,6 +70,7 @@ export function GetCardData({
accounts,
categories,
earliestTransaction,
latestTransaction,
firstDayOfWeekIdx,
showTooltip,
}: {
@@ -78,6 +79,7 @@ export function GetCardData({
accounts: AccountEntity[];
categories: { list: CategoryEntity[]; grouped: CategoryGroupEntity[] };
earliestTransaction: string;
latestTransaction: string;
firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'];
showTooltip?: boolean;
}) {
@@ -90,6 +92,7 @@ export function GetCardData({
const [dateStart, dateEnd] = getLiveRange(
report.dateRange,
earliestTransaction,
latestTransaction,
report.includeCurrentInterval,
firstDayOfWeekIdx,
);

View File

@@ -125,22 +125,40 @@ function NetWorthInner({ widget }: NetWorthInnerProps) {
const data = useReport('net_worth', reportParams);
useEffect(() => {
async function run() {
const trans = await send('get-earliest-transaction');
const earliestTransaction = await send('get-earliest-transaction');
setEarliestTransaction(
earliestTransaction
? earliestTransaction.date
: monthUtils.currentDay(),
);
const latestTransaction = await send('get-latest-transaction');
setLatestTransaction(
latestTransaction ? latestTransaction.date : monthUtils.currentDay(),
);
const currentMonth = monthUtils.currentMonth();
let earliestMonth = trans
? monthUtils.monthFromDate(d.parseISO(fromDateRepr(trans.date)))
let earliestMonth = earliestTransaction
? monthUtils.monthFromDate(
d.parseISO(fromDateRepr(earliestTransaction.date)),
)
: currentMonth;
const latestMonth = latestTransaction
? monthUtils.monthFromDate(
d.parseISO(fromDateRepr(latestTransaction.date)),
)
: currentMonth;
// Make sure the month selects are at least populates with a
// year's worth of months. We can undo this when we have fancier
// date selects.
const yearAgo = monthUtils.subMonths(monthUtils.currentMonth(), 12);
const yearAgo = monthUtils.subMonths(latestMonth, 12);
if (earliestMonth > yearAgo) {
earliestMonth = yearAgo;
}
const allMonths = monthUtils
.rangeInclusive(earliestMonth, monthUtils.currentMonth())
.rangeInclusive(earliestMonth, latestMonth)
.map(month => ({
name: month,
pretty: monthUtils.format(month, 'MMMM, yyyy', locale),
@@ -206,7 +224,8 @@ function NetWorthInner({ widget }: NetWorthInnerProps) {
});
};
const [earliestTransaction, _] = useState('');
const [earliestTransaction, setEarliestTransaction] = useState('');
const [latestTransaction, setLatestTransaction] = useState('');
if (!allMonths || !data) {
return null;
@@ -244,6 +263,7 @@ function NetWorthInner({ widget }: NetWorthInnerProps) {
start={start}
end={end}
earliestTransaction={earliestTransaction}
latestTransaction={latestTransaction}
firstDayOfWeekIdx={firstDayOfWeekIdx}
mode={mode}
onChangeDates={onChangeDates}

View File

@@ -100,22 +100,27 @@ function SpendingInternal({ widget }: SpendingInternalProps) {
useEffect(() => {
async function run() {
const trans = await send('get-earliest-transaction');
const earliestTrans = await send('get-earliest-transaction');
const latestTrans = await send('get-latest-transaction');
let earliestMonth = trans
? monthUtils.monthFromDate(d.parseISO(fromDateRepr(trans.date)))
: monthUtils.currentMonth();
const currentMonth = monthUtils.currentMonth();
let earliestMonth = earliestTrans
? monthUtils.monthFromDate(d.parseISO(fromDateRepr(earliestTrans.date)))
: currentMonth;
const latestMonth = latestTrans
? monthUtils.monthFromDate(d.parseISO(fromDateRepr(latestTrans.date)))
: currentMonth;
// Make sure the month selects are at least populates with a
// year's worth of months. We can undo this when we have fancier
// date selects.
const yearAgo = monthUtils.subMonths(monthUtils.currentMonth(), 12);
const yearAgo = monthUtils.subMonths(latestMonth, 12);
if (earliestMonth > yearAgo) {
earliestMonth = yearAgo;
}
const allMonths = monthUtils
.rangeInclusive(earliestMonth, monthUtils.currentMonth())
.rangeInclusive(earliestMonth, latestMonth)
.map(month => ({
name: month,
pretty: monthUtils.format(month, 'MMMM, yyyy', locale),

View File

@@ -160,28 +160,47 @@ function SummaryInner({ widget }: SummaryInnerProps) {
}>
>([]);
const [earliestTransaction, _] = useState('');
const [earliestTransaction, setEarliestTransaction] = useState('');
const [latestTransaction, setLatestTransaction] = useState('');
const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0';
useEffect(() => {
async function run() {
const trans = await send('get-earliest-transaction');
const earliestTransaction = await send('get-earliest-transaction');
setEarliestTransaction(
earliestTransaction
? earliestTransaction.date
: monthUtils.currentDay(),
);
const latestTransaction = await send('get-latest-transaction');
setLatestTransaction(
latestTransaction ? latestTransaction.date : monthUtils.currentDay(),
);
const currentMonth = monthUtils.currentMonth();
let earliestMonth = trans
? monthUtils.monthFromDate(parseISO(fromDateRepr(trans.date)))
let earliestMonth = earliestTransaction
? monthUtils.monthFromDate(
parseISO(fromDateRepr(earliestTransaction.date)),
)
: currentMonth;
const latestMonth = latestTransaction
? monthUtils.monthFromDate(
parseISO(fromDateRepr(latestTransaction.date)),
)
: currentMonth;
// Make sure the month selects are at least populates with a
// year's worth of months. We can undo this when we have fancier
// date selects.
const yearAgo = monthUtils.subMonths(monthUtils.currentMonth(), 12);
const yearAgo = monthUtils.subMonths(latestMonth, 12);
if (earliestMonth > yearAgo) {
earliestMonth = yearAgo;
}
const allMonths = monthUtils
.rangeInclusive(earliestMonth, monthUtils.currentMonth())
.rangeInclusive(earliestMonth, latestMonth)
.map(month => ({
name: month,
pretty: monthUtils.format(month, 'MMMM, yyyy', locale),
@@ -305,6 +324,7 @@ function SummaryInner({ widget }: SummaryInnerProps) {
start={start}
end={end}
earliestTransaction={earliestTransaction}
latestTransaction={latestTransaction}
firstDayOfWeekIdx={firstDayOfWeekIdx}
mode={mode}
onChangeDates={onChangeDates}

View File

@@ -26,6 +26,7 @@ export type TransactionHandlers = {
'transactions-export-query': typeof exportTransactionsQuery;
'transactions-merge': typeof mergeTransactions;
'get-earliest-transaction': typeof getEarliestTransaction;
'get-latest-transaction': typeof getLatestTransaction;
};
async function handleBatchUpdateTransactions({
@@ -104,6 +105,17 @@ async function getEarliestTransaction() {
return data[0] || null;
}
async function getLatestTransaction() {
const { data } = await aqlQuery(
q('transactions')
.options({ splits: 'none' })
.orderBy({ date: 'desc' })
.select('*')
.limit(1),
);
return data[0] || null;
}
export const app = createApp<TransactionHandlers>();
app.method(
@@ -119,3 +131,4 @@ app.method('transactions-parse-file', mutator(parseTransactionsFile));
app.method('transactions-export', mutator(exportTransactions));
app.method('transactions-export-query', mutator(exportTransactionsQuery));
app.method('get-earliest-transaction', getEarliestTransaction);
app.method('get-latest-transaction', getLatestTransaction);

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [csenel]
---
Extend report end date to the latest transaction date