From d29e27c10d9367a258f6d4a295a72cf2595cc60a Mon Sep 17 00:00:00 2001 From: Sascha Kappmeier Date: Wed, 28 Oct 2026 13:49:12 +0100 Subject: [PATCH 1/4] feat(reports): let dashboard charts and reports count budget months (#457) A new setting next to the Budget Cycle Start Day chooses whether the dashboard charts and the month-by-month reports (income vs. expenses trend, cash flow, category by month, and their exports) count calendar months, the default, or the user's budget months. With budget months a salary on the 26th counts towards the next month, as on the Budget page. The queries group by date and fold the rows into budget months in PHP, since a budget month can't be cut out of a date in SQL all supported databases run. Year-over-year and the digest keep calendar months. The hero tiles' "vs last month" now compares the current budget period with the whole previous one. It used the summary's calendar-month buckets, which with a start day cut the current period in two. Co-Authored-By: Claude Opus 5.5 --- budget/css/style.css | 6 + budget/lib/Controller/SettingController.php | 2 + budget/lib/Db/ReportScope.php | 31 ++++ budget/lib/Db/TransactionReportQueries.php | 56 +++++-- budget/lib/Service/BudgetCarryoverService.php | 14 ++ budget/lib/Service/BudgetPeriod.php | 25 ++++ .../lib/Service/Report/ReportAggregator.php | 89 +++++++---- budget/lib/Service/ReportService.php | 22 ++- .../src/modules/dashboard/DashboardModule.js | 108 ++++++++------ budget/src/modules/reports/ReportsModule.js | 3 + budget/src/utils/formatters.js | 30 ++++ budget/templates/index.php | 15 ++ .../Unit/Db/TransactionReportQueriesTest.php | 68 +++++++++ .../Service/BudgetCarryoverServiceTest.php | 33 ++++ .../tests/Unit/Service/BudgetPeriodTest.php | 20 +++ .../Service/Report/ReportAggregatorTest.php | 51 +++++++ .../tests/Unit/Service/ReportServiceTest.php | 33 ++++ budget/tests/js/reportMonthGrouping.test.js | 141 ++++++++++++++++++ 18 files changed, 653 insertions(+), 94 deletions(-) create mode 100644 budget/tests/js/reportMonthGrouping.test.js diff --git a/budget/css/style.css b/budget/css/style.css index 8fa820c62..17d9f87b8 100644 --- a/budget/css/style.css +++ b/budget/css/style.css @@ -6879,6 +6879,12 @@ button.danger:hover { .category-chart-header h5 { margin: 0; } +/* Says a chart's or report's months are budget months (#457) */ +.month-grouping-note { + margin: 4px 0 0; + font-size: 12px; + color: var(--color-text-maxcontrast); +} /* Says the bars are budget months when a budget start day is set (#447) */ .category-chart-note { margin: -8px 0 12px; diff --git a/budget/lib/Controller/SettingController.php b/budget/lib/Controller/SettingController.php index 3ea2d13f3..0cd50b6d1 100644 --- a/budget/lib/Controller/SettingController.php +++ b/budget/lib/Controller/SettingController.php @@ -10,6 +10,7 @@ use OCA\Budget\Enum\Currency; use OCA\Budget\Service\AmountColours; use OCA\Budget\Service\AttachmentService; +use OCA\Budget\Service\BudgetCarryoverService; use OCA\Budget\Service\GranularShareService; use OCA\Budget\Service\ImportFolderService; use OCA\Budget\Traits\ApiErrorHandlerTrait; @@ -56,6 +57,7 @@ class SettingController extends Controller { 'export_default_format' => 'csv', 'budget_period' => 'monthly', 'budget_start_day' => '1', + BudgetCarryoverService::REPORT_MONTHS_SETTING => 'calendar', // or 'budget' (#457) 'budget_alert_threshold' => '80', 'budget_alert_scope' => 'all', // or 'manual': only budgets the user set (#389) 'budget_alert_muted_categories' => '[]', diff --git a/budget/lib/Db/ReportScope.php b/budget/lib/Db/ReportScope.php index 4d44adeff..39cef023b 100644 --- a/budget/lib/Db/ReportScope.php +++ b/budget/lib/Db/ReportScope.php @@ -4,6 +4,7 @@ namespace OCA\Budget\Db; +use OCA\Budget\Service\BudgetPeriod; use OCA\Budget\Service\MoneyCalculator; use OCP\DB\QueryBuilder\ICompositeExpression; use OCP\DB\QueryBuilder\IQueryBuilder; @@ -43,6 +44,36 @@ public static function monthExpr(string $alias = 't'): string { return "SUBSTR(CAST({$alias}.date AS CHAR(10)), 1, 7)"; } + /** + * The bucket a month-by-month report groups by. Calendar months + * ($monthStartDay 1) are cut in SQL. A budget month can't be cut out of + * a date in SQL every database runs, so with a budget start day the + * query groups by date and toBudgetMonths() names each date's month. + */ + public static function monthBucketExpr(int $monthStartDay, string $alias = 't'): string { + return $monthStartDay > 1 ? "CAST({$alias}.date AS CHAR(10))" : self::monthExpr($alias); + } + + /** + * Rows grouped by monthBucketExpr(), with $column turned from a date into + * its budget month (Y-m). Several dates then share a month, so the rows + * go through mergeReportHalves() afterwards to add them up. Calendar + * month rows come back unchanged. + * + * @param array[] $rows + * @return array[] + */ + public static function toBudgetMonths(array $rows, int $monthStartDay, string $column = 'month'): array { + if ($monthStartDay <= 1) { + return $rows; + } + foreach ($rows as &$row) { + $row[$column] = BudgetPeriod::monthContaining(substr((string)$row[$column], 0, 10), $monthStartDay); + } + unset($row); + return $rows; + } + /** * Apply user scope to a query — either by userId or by visible account IDs. * Used for granular sharing where the user can see specific shared accounts. diff --git a/budget/lib/Db/TransactionReportQueries.php b/budget/lib/Db/TransactionReportQueries.php index 0738ed0a1..adbdb7b9e 100644 --- a/budget/lib/Db/TransactionReportQueries.php +++ b/budget/lib/Db/TransactionReportQueries.php @@ -217,6 +217,9 @@ function (IQueryBuilder $qb, string $alloc): void { * * @param int[] $tagIds Optional tag filter (OR logic) * @param bool $includeUntagged Include untagged transactions when filtering by tags + * @param int $monthStartDay 1 for calendar months; otherwise the budget + * start day, and 'month' is the budget month + * (BudgetPeriod) each transaction falls in * @return array */ public function getCashFlowByMonth( @@ -228,19 +231,27 @@ public function getCashFlowByMonth( bool $includeUntagged = true, bool $excludeTransfers = false, ?array $visibleAccountIds = null, + int $monthStartDay = 1, ): array { + $bucket = ReportScope::monthBucketExpr($monthStartDay); [$direct, $split] = $this->reportHalves( $userId, $accountId, $startDate, $endDate, $visibleAccountIds, $excludeTransfers, - function (IQueryBuilder $qb, string $alloc) use ($tagIds, $includeUntagged): void { - $qb->select($qb->createFunction(ReportScope::monthExpr() . ' as month')); + function (IQueryBuilder $qb, string $alloc) use ($tagIds, $includeUntagged, $bucket): void { + $qb->select($qb->createFunction($bucket . ' as month')); ReportScope::selectIncomeExpenses($qb, $alloc); $qb->selectAlias($qb->createFunction('COUNT(DISTINCT t.id)'), 'count'); ReportScope::applyTagFilter($qb, $tagIds, $includeUntagged); - $qb->groupBy($qb->createFunction(ReportScope::monthExpr())); + $qb->groupBy($qb->createFunction($bucket)); } ); - $rows = ReportScope::mergeReportHalves($direct, $split, ['month'], ['income', 'expenses'], ['count']); + // A transaction has one date, so adding up the counts of a budget + // month's dates counts each transaction once + $rows = ReportScope::mergeReportHalves( + ReportScope::toBudgetMonths($direct, $monthStartDay), + ReportScope::toBudgetMonths($split, $monthStartDay), + ['month'], ['income', 'expenses'], ['count'] + ); usort($rows, static fn (array $a, array $b) => strcmp((string)$a['month'], (string)$b['month'])); return array_map(fn (array $row) => [ @@ -269,20 +280,26 @@ public function getCashFlowByMonthByAccount( bool $includeUntagged = true, bool $excludeTransfers = false, ?array $visibleAccountIds = null, + int $monthStartDay = 1, ): array { + $bucket = ReportScope::monthBucketExpr($monthStartDay); [$direct, $split] = $this->reportHalves( $userId, null, $startDate, $endDate, $visibleAccountIds, $excludeTransfers, - function (IQueryBuilder $qb, string $alloc) use ($tagIds, $includeUntagged): void { + function (IQueryBuilder $qb, string $alloc) use ($tagIds, $includeUntagged, $bucket): void { $qb->select('t.account_id') - ->addSelect($qb->createFunction(ReportScope::monthExpr() . ' as month')); + ->addSelect($qb->createFunction($bucket . ' as month')); ReportScope::selectIncomeExpenses($qb, $alloc); $qb->selectAlias($qb->createFunction('COUNT(DISTINCT t.id)'), 'count'); ReportScope::applyTagFilter($qb, $tagIds, $includeUntagged); - $qb->groupBy('t.account_id', $qb->createFunction(ReportScope::monthExpr())); + $qb->groupBy('t.account_id', $qb->createFunction($bucket)); } ); - $rows = ReportScope::mergeReportHalves($direct, $split, ['account_id', 'month'], ['income', 'expenses'], ['count']); + $rows = ReportScope::mergeReportHalves( + ReportScope::toBudgetMonths($direct, $monthStartDay), + ReportScope::toBudgetMonths($split, $monthStartDay), + ['account_id', 'month'], ['income', 'expenses'], ['count'] + ); usort($rows, static fn (array $a, array $b) => strcmp((string)$a['month'], (string)$b['month'])); return array_map(static fn (array $row) => [ @@ -315,13 +332,14 @@ public function getMonthlyTrendData( bool $includeUntagged = true, bool $excludeTransfers = false, ?array $visibleAccountIds = null, + int $monthStartDay = 1, ): array { return array_map(static fn (array $row) => [ 'month' => $row['month'], 'income' => $row['income'], 'expenses' => $row['expenses'], ], $this->getCashFlowByMonth( - $userId, $accountId, $startDate, $endDate, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds + $userId, $accountId, $startDate, $endDate, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds, $monthStartDay )); } @@ -342,6 +360,7 @@ public function getMonthlyTrendDataByAccount( bool $includeUntagged = true, bool $excludeTransfers = false, ?array $visibleAccountIds = null, + int $monthStartDay = 1, ): array { return array_map(static fn (array $row) => [ 'month' => $row['month'], @@ -349,7 +368,7 @@ public function getMonthlyTrendDataByAccount( 'income' => $row['income'], 'expenses' => $row['expenses'], ], $this->getCashFlowByMonthByAccount( - $userId, $startDate, $endDate, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds + $userId, $startDate, $endDate, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds, $monthStartDay )); } @@ -786,6 +805,8 @@ function (IQueryBuilder $qb, string $alloc) use ($tagIds): void { * under key UNCATEGORIZED, so the report's totals match the month view. * * @param int[]|null $visibleAccountIds + * @param int $monthStartDay 1 for calendar months, else budget months + * starting on that day (see getCashFlowByMonth()) * @return array> categoryId => 'YYYY-MM' => net */ public function getCategoryNetByMonth( @@ -794,20 +815,27 @@ public function getCategoryNetByMonth( string $endDate, ?int $accountId = null, ?array $visibleAccountIds = null, + int $monthStartDay = 1, ): array { + $bucket = ReportScope::monthBucketExpr($monthStartDay); [$direct, $split] = $this->reportHalves( $userId, $accountId, $startDate, $endDate, $visibleAccountIds, $accountId === null, - function (IQueryBuilder $qb, string $alloc): void { + function (IQueryBuilder $qb, string $alloc) use ($bucket): void { $qb->select("{$alloc}.category_id") - ->addSelect($qb->createFunction(ReportScope::monthExpr() . ' as month')) + ->addSelect($qb->createFunction($bucket . ' as month')) ->selectAlias($qb->createFunction(ReportScope::signedAmountSum($qb, 'credit', "{$alloc}.amount")), 'net') ->groupBy("{$alloc}.category_id") - ->addGroupBy($qb->createFunction(ReportScope::monthExpr())); + ->addGroupBy($qb->createFunction($bucket)); } ); $totals = []; - foreach (ReportScope::mergeReportHalves($direct, $split, ['category_id', 'month'], ['net']) as $row) { + $merged = ReportScope::mergeReportHalves( + ReportScope::toBudgetMonths($direct, $monthStartDay), + ReportScope::toBudgetMonths($split, $monthStartDay), + ['category_id', 'month'], ['net'] + ); + foreach ($merged as $row) { $catId = $row['category_id'] === null ? self::UNCATEGORIZED : (int)$row['category_id']; $totals[$catId][substr((string)$row['month'], 0, 7)] = $row['net']; } diff --git a/budget/lib/Service/BudgetCarryoverService.php b/budget/lib/Service/BudgetCarryoverService.php index b968d2a8e..3207ca9df 100644 --- a/budget/lib/Service/BudgetCarryoverService.php +++ b/budget/lib/Service/BudgetCarryoverService.php @@ -44,6 +44,9 @@ */ class BudgetCarryoverService { + /** 'calendar' (the default) or 'budget': how the charts and reports cut months */ + public const REPORT_MONTHS_SETTING = 'report_month_grouping'; + /** Hard cap on chain length — keeps multi-year anchors bounded */ private const MAX_CHAIN_MONTHS = 60; @@ -460,6 +463,17 @@ public function budgetStartDay(string $userId): int { return max(1, min(31, $startDay)); } + /** + * The day the months of the dashboard charts and the month-by-month + * reports start on: the budget start day when the user chose budget + * months for them in Settings, otherwise 1, calendar months (#457). + */ + public function reportMonthStartDay(string $userId): int { + return $this->settingService->get($userId, self::REPORT_MONTHS_SETTING) === 'budget' + ? $this->budgetStartDay($userId) + : 1; + } + /** * Today (Y-m-d) on the user's calendar, not the server's: in Sydney the * server is still on last month until mid-morning on the 1st, and the diff --git a/budget/lib/Service/BudgetPeriod.php b/budget/lib/Service/BudgetPeriod.php index bef65bba6..1bb2e20f5 100644 --- a/budget/lib/Service/BudgetPeriod.php +++ b/budget/lib/Service/BudgetPeriod.php @@ -74,6 +74,31 @@ public static function monthContaining(string $date, int $startDay): string { return substr($date, 0, 7); } + /** + * The budget months (Y-m) a range touches, in order: from the one + * holding $start to the one holding $end. With start day 1 these are + * the calendar months. Empty when the range is reversed. + * + * @return list + */ + public static function monthsBetween(string $start, string $end, int $startDay): array { + if ($start > $end) { + return []; + } + $months = []; + $month = \DateTime::createFromFormat('!Y-m', self::monthContaining($start, $startDay)); + $last = self::monthContaining($end, $startDay); + // Bounded: a thousand years of months is more than any range asks for + for ($guard = 0; $guard < 12000; $guard++) { + $months[] = $month->format('Y-m'); + if ($months[count($months) - 1] >= $last) { + break; + } + $month->modify('first day of next month'); + } + return $months; + } + /** * Budget amounts of any periods as one monthly total, by the yearly * ratios the Budget page's summary uses (formatters.prorateBudget): each diff --git a/budget/lib/Service/Report/ReportAggregator.php b/budget/lib/Service/Report/ReportAggregator.php index cf0da33b3..9e71aa96b 100644 --- a/budget/lib/Service/Report/ReportAggregator.php +++ b/budget/lib/Service/Report/ReportAggregator.php @@ -95,6 +95,7 @@ public function generateSummary( array $tagIds = [], bool $includeUntagged = true, array $visibleAccountIds = [], + int $monthStartDay = 1, ): array { if ($accountId) { // One account selected: an account shared with the user is in @@ -383,7 +384,7 @@ public function generateSummary( $summary['spending'] = $this->spendingInBase($userId, $accountId, !empty($visibleAccountIds) ? $visibleAccountIds : null, $spendingQuery); // Generate trend data (with currency conversion for multi-account view) - $summary['trends'] = $this->generateTrendData($userId, $accountId, $startDate, $endDate, $tagIds, $includeUntagged, !empty($visibleAccountIds) ? $visibleAccountIds : null); + $summary['trends'] = $this->generateTrendData($userId, $accountId, $startDate, $endDate, $tagIds, $includeUntagged, !empty($visibleAccountIds) ? $visibleAccountIds : null, $monthStartDay); return $summary; } @@ -401,9 +402,10 @@ public function generateSummaryWithComparison( array $tagIds = [], bool $includeUntagged = true, array $visibleAccountIds = [], + int $monthStartDay = 1, ): array { // Current period - $current = $this->generateSummary($userId, $accountId, $startDate, $endDate, $tagIds, $includeUntagged, $visibleAccountIds); + $current = $this->generateSummary($userId, $accountId, $startDate, $endDate, $tagIds, $includeUntagged, $visibleAccountIds, $monthStartDay); // Calculate previous period (same duration) $start = new \DateTime($startDate); @@ -422,7 +424,8 @@ public function generateSummaryWithComparison( $prevEnd->format('Y-m-d'), $tagIds, $includeUntagged, - $visibleAccountIds + $visibleAccountIds, + $monthStartDay ); // Calculate changes @@ -652,6 +655,7 @@ public function getCashFlowReport( array $tagIds = [], bool $includeUntagged = true, ?array $visibleAccountIds = null, + int $monthStartDay = 1, ): array { $excludeTransfers = $accountId === null; @@ -668,18 +672,18 @@ public function getCashFlowReport( if ($needsConversion) { $currencyMap = $this->conversionService->getAccountCurrencyMap($accounts); $cashFlow = $this->convertCashFlowByAccount( - $userId, $startDate, $endDate, $currencyMap, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds + $userId, $startDate, $endDate, $currencyMap, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds, $monthStartDay ); } else { $cashFlow = $this->reportQueries->getCashFlowByMonth( - $userId, $accountId, $startDate, $endDate, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds + $userId, $accountId, $startDate, $endDate, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds, $monthStartDay ); } } else { // Still within the viewer's accounts: without them the query is // scoped to the viewer's own, and a shared account came back empty $cashFlow = $this->reportQueries->getCashFlowByMonth( - $userId, $accountId, $startDate, $endDate, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds + $userId, $accountId, $startDate, $endDate, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds, $monthStartDay ); } @@ -694,7 +698,7 @@ public function getCashFlowReport( $monthCount = count($cashFlow); return [ - 'period' => ['startDate' => $startDate, 'endDate' => $endDate], + 'period' => ['startDate' => $startDate, 'endDate' => $endDate, 'monthStartDay' => $monthStartDay], 'data' => $cashFlow, 'totals' => $totals, 'averageMonthly' => [ @@ -719,9 +723,10 @@ private function convertCashFlowByAccount( bool $includeUntagged, bool $excludeTransfers, ?array $visibleAccountIds = null, + int $monthStartDay = 1, ): array { $perAccountData = $this->reportQueries->getCashFlowByMonthByAccount( - $userId, $startDate, $endDate, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds + $userId, $startDate, $endDate, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds, $monthStartDay ); $baseCurrency = $this->conversionService->getBaseCurrency($userId); @@ -769,6 +774,7 @@ public function generateTrendData( array $tagIds = [], bool $includeUntagged = true, ?array $visibleAccountIds = null, + int $monthStartDay = 1, ): array { $excludeTransfers = $accountId === null; @@ -784,11 +790,11 @@ public function generateTrendData( if ($needsConversion) { $currencyMap = $this->conversionService->getAccountCurrencyMap($accounts); $dataByMonth = $this->convertTrendDataByAccount( - $userId, $startDate, $endDate, $currencyMap, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds + $userId, $startDate, $endDate, $currencyMap, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds, $monthStartDay ); } else { $monthlyData = $this->reportQueries->getMonthlyTrendData( - $userId, $accountId, $startDate, $endDate, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds + $userId, $accountId, $startDate, $endDate, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds, $monthStartDay ); $dataByMonth = []; foreach ($monthlyData as $row) { @@ -798,7 +804,7 @@ public function generateTrendData( } else { // Within the viewer's accounts, as getCashFlowReport() does $monthlyData = $this->reportQueries->getMonthlyTrendData( - $userId, $accountId, $startDate, $endDate, $tagIds, $includeUntagged, false, $visibleAccountIds + $userId, $accountId, $startDate, $endDate, $tagIds, $includeUntagged, false, $visibleAccountIds, $monthStartDay ); $dataByMonth = []; foreach ($monthlyData as $row) { @@ -808,29 +814,22 @@ public function generateTrendData( // `months` (Y-m) is what the web UI formats, in the user's own // language; `labels` stays as the English "M Y" for anything that - // still reads it. + // still reads it. With a month start day they are budget months. $trends = [ 'labels' => [], 'months' => [], 'income' => [], - 'expenses' => [] + 'expenses' => [], + 'monthStartDay' => $monthStartDay, ]; - $start = new \DateTime($startDate); - $end = new \DateTime($endDate); - $interval = new \DateInterval('P1M'); - - $current = clone $start; - while ($current <= $end) { - $month = $current->format('Y-m'); - $trends['labels'][] = $current->format('M Y'); + foreach ($this->trendMonths($startDate, $endDate, $monthStartDay) as $month) { + $trends['labels'][] = \DateTime::createFromFormat('!Y-m', $month)->format('M Y'); $trends['months'][] = $month; $monthData = $dataByMonth[$month] ?? ['income' => 0, 'expenses' => 0]; $trends['income'][] = $monthData['income']; $trends['expenses'][] = $monthData['expenses']; - - $current->add($interval); } return $trends; @@ -855,7 +854,7 @@ public function generateTrendData( * * @param string $sort 'alpha' (default) or 'total' * @param int[] $visibleAccountIds - * @return array{period: array, sort: string, baseCurrency: string, rows: array, totals: array} + * @return array{period: array, sort: string, baseCurrency: string, mixedCurrency: bool, rows: array, totals: array} */ public function getCategoryMonthlyReport( string $userId, @@ -864,14 +863,15 @@ public function getCategoryMonthlyReport( ?int $accountId = null, string $sort = 'alpha', array $visibleAccountIds = [], + int $monthStartDay = 1, ): array { $sort = $sort === 'total' ? 'total' : 'alpha'; $vis = !empty($visibleAccountIds) ? $visibleAccountIds : null; - $months = $this->buildMonthList($startDate, $endDate); + $months = $this->buildMonthList($startDate, $endDate, $monthStartDay); // Signed net per category per month: each category's OWN amounts // (direct + split parts), already report-scoped in SQL - $own = $this->reportQueries->getCategoryNetByMonth($userId, $startDate, $endDate, $accountId, $vis); + $own = $this->reportQueries->getCategoryNetByMonth($userId, $startDate, $endDate, $accountId, $vis, $monthStartDay); // Rows: a category excluded from reports, or muted by this viewer, // carries no money (the query dropped it) and is not shown either; @@ -1030,7 +1030,7 @@ public function getCategoryMonthlyReport( $mixedCurrency = $this->conversionService->needsConversion($reportAccounts); return [ - 'period' => ['startDate' => $startDate, 'endDate' => $endDate, 'months' => $months], + 'period' => ['startDate' => $startDate, 'endDate' => $endDate, 'months' => $months, 'monthStartDay' => $monthStartDay], 'sort' => $sort, 'baseCurrency' => $this->conversionService->getBaseCurrency($userId), 'mixedCurrency' => $mixedCurrency, @@ -1040,11 +1040,39 @@ public function getCategoryMonthlyReport( } /** - * List of 'YYYY-MM' month keys from startDate to endDate inclusive. + * The months of a trend series. Calendar months step from the start date + * as they always have; with a month start day they are the budget + * months the range touches. + * + * @return string[] 'YYYY-MM' keys + */ + private function trendMonths(string $startDate, string $endDate, int $monthStartDay): array { + if ($monthStartDay > 1) { + return BudgetPeriod::monthsBetween($startDate, $endDate, $monthStartDay); + } + $months = []; + $current = new \DateTime($startDate); + $end = new \DateTime($endDate); + $interval = new \DateInterval('P1M'); + while ($current <= $end) { + $months[] = $current->format('Y-m'); + $current->add($interval); + } + return $months; + } + + /** + * List of 'YYYY-MM' month keys from startDate to endDate inclusive: + * calendar months, or with a month start day the budget months the + * range touches. * * @return string[] */ - private function buildMonthList(string $startDate, string $endDate): array { + private function buildMonthList(string $startDate, string $endDate, int $monthStartDay = 1): array { + if ($monthStartDay > 1) { + // The same guard as the calendar months below + return array_slice(BudgetPeriod::monthsBetween($startDate, $endDate, $monthStartDay), 0, 600); + } $months = []; $cur = strtotime(substr($startDate, 0, 7) . '-01'); $end = strtotime(substr($endDate, 0, 7) . '-01'); @@ -1097,9 +1125,10 @@ private function convertTrendDataByAccount( bool $includeUntagged, bool $excludeTransfers, ?array $visibleAccountIds = null, + int $monthStartDay = 1, ): array { $perAccountData = $this->reportQueries->getMonthlyTrendDataByAccount( - $userId, $startDate, $endDate, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds + $userId, $startDate, $endDate, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds, $monthStartDay ); $baseCurrency = $this->conversionService->getBaseCurrency($userId); diff --git a/budget/lib/Service/ReportService.php b/budget/lib/Service/ReportService.php index b2097eb87..c44f191a4 100644 --- a/budget/lib/Service/ReportService.php +++ b/budget/lib/Service/ReportService.php @@ -25,6 +25,7 @@ public function __construct( ReportExporter $exporter, TagReportService $tagReportService, AmountColours $amountColours, + private ?BudgetCarryoverService $carryoverService = null, ) { $this->calculator = $calculator; $this->aggregator = $aggregator; @@ -33,6 +34,15 @@ public function __construct( $this->amountColours = $amountColours; } + /** + * The day the months of the month-by-month figures start on: 1 for + * calendar months, or the budget start day when the user chose budget + * months for the charts and reports (#457). + */ + private function monthStartDay(string $userId): int { + return $this->carryoverService?->reportMonthStartDay($userId) ?? 1; + } + /** * Generate a comprehensive financial summary. * @param int[] $tagIds Optional tag filter (OR logic) @@ -54,7 +64,8 @@ public function generateSummary( $endDate, $tagIds, $includeUntagged, - $visibleAccountIds + $visibleAccountIds, + $this->monthStartDay($userId) ); } @@ -79,7 +90,8 @@ public function generateSummaryWithComparison( $endDate, $tagIds, $includeUntagged, - $visibleAccountIds + $visibleAccountIds, + $this->monthStartDay($userId) ); } @@ -255,7 +267,8 @@ public function getCashFlowReport( $endDate, $tagIds, $includeUntagged, - $visibleAccountIds + $visibleAccountIds, + $this->monthStartDay($userId) ); } @@ -396,7 +409,8 @@ public function getCategoryMonthlyReport( $endDate, $accountId, $sort, - $visibleAccountIds ?? [] + $visibleAccountIds ?? [], + $this->monthStartDay($userId) ); } diff --git a/budget/src/modules/dashboard/DashboardModule.js b/budget/src/modules/dashboard/DashboardModule.js index c9740328f..7b19c2769 100644 --- a/budget/src/modules/dashboard/DashboardModule.js +++ b/budget/src/modules/dashboard/DashboardModule.js @@ -180,12 +180,17 @@ export default class DashboardModule { const periodEnd = currentPeriod.end; const periodMonth = formatters.budgetMonthForCycle(periodStart, periodEnd); - // The trend and spending charts are calendar-month series, so they - // stop at this month's end; a cycle ending next month would add an - // empty bar for it. - const sixMonthsAgoDate = new Date(now.getFullYear(), now.getMonth() - 5, 1); - const sixMonthsAgo = formatters.getMonthStart(sixMonthsAgoDate.getFullYear(), sixMonthsAgoDate.getMonth() + 1); - const endOfMonth = formatters.getMonthEnd(now.getFullYear(), now.getMonth() + 1); + // The trend and spending charts are month series: calendar months, + // or budget months when the user chose them in Settings (#457). + // They cover whole months and stop at the current one's end; a + // calendar series running to a cycle's end in next month would + // add an empty bar for it. + const chartStartDay = formatters.reportMonthStartDay(this.settings); + const chartMonth = formatters.currentBudgetMonth(chartStartDay, now); + const sixMonthsAgo = formatters.budgetMonthRange(formatters.shiftMonth(chartMonth, -5), chartStartDay).start; + const endOfMonth = formatters.budgetMonthRange(chartMonth, chartStartDay).end; + // The hero tiles compare this budget period with the one before + const previousPeriod = formatters.budgetMonthRange(formatters.shiftMonth(periodMonth, -1), budgetStartDay); // Cache-busting timestamp to ensure fresh data const cacheBuster = Date.now(); @@ -202,9 +207,11 @@ export default class DashboardModule { }; // Load all dashboard data in parallel for better performance - const [summary, trendData, transactions, billsRaw, budgetDataRaw, goalsRaw, pensionRaw, assetRaw, netWorthRaw, alertsRaw, debtSummary, assetValueHistory] = await Promise.all([ + const [summary, previousSummary, trendData, transactions, billsRaw, budgetDataRaw, goalsRaw, pensionRaw, assetRaw, netWorthRaw, alertsRaw, debtSummary, assetValueHistory] = await Promise.all([ // Current budget cycle summary for hero stats apiFetch(`/apps/budget/api/reports/summary?startDate=${periodStart}&endDate=${periodEnd}&_=${cacheBuster}`).catch(orEmpty({})), + // The cycle before, for the hero tiles' "vs last month" + apiFetch(`/apps/budget/api/reports/summary?startDate=${previousPeriod.start}&endDate=${previousPeriod.end}&_=${cacheBuster}`).catch(orEmpty({})), // 6-month summary for trend charts apiFetch(`/apps/budget/api/reports/summary?startDate=${sixMonthsAgo}&endDate=${endOfMonth}&_=${cacheBuster}`).catch(orEmpty({})), apiFetch(`/apps/budget/api/transactions?limit=${this._recentTxLimit()}`).catch(orEmpty([])), @@ -228,6 +235,7 @@ export default class DashboardModule { const budgetAlerts = alertsRaw ?? []; // Update Hero Section (current month data) + summary.previousTotals = previousSummary.totals || null; this.updateDashboardHero(summary, pensionSummary, assetSummary); this.updateHeroPeriodHints(budgetStartDay > 1 ? currentPeriod.label : null); @@ -394,18 +402,18 @@ export default class DashboardModule { incomeEl.textContent = this.formatCurrency(totals.totalIncome || 0, currency); } - // Calculate month-over-month change for income + // Calculate month-over-month change for income: this budget period + // against the whole one before. The summary's trend buckets are + // calendar months, which with a start day cut this period in two. + const previous = summary.previousTotals; const incomeChangeEl = document.getElementById('hero-income-change'); - if (incomeChangeEl && summary.trends && summary.trends.income) { - const incomeData = summary.trends.income; - if (incomeData.length >= 2) { - const currentMonth = incomeData[incomeData.length - 1] || 0; - const lastMonth = incomeData[incomeData.length - 2] || 0; - const change = lastMonth > 0 ? ((currentMonth - lastMonth) / lastMonth * 100) : 0; - if (change !== 0) { - incomeChangeEl.innerHTML = `${change >= 0 ? '↑' : '↓'} ${t('budget', '{percent}% vs last month', { percent: Math.abs(change).toFixed(1) })}`; - incomeChangeEl.className = `hero-change ${change >= 0 ? 'positive' : 'negative'}`; - } + if (incomeChangeEl && previous) { + const currentMonth = totals.totalIncome || 0; + const lastMonth = previous.totalIncome || 0; + const change = lastMonth > 0 ? ((currentMonth - lastMonth) / lastMonth * 100) : 0; + if (change !== 0) { + incomeChangeEl.innerHTML = `${change >= 0 ? '↑' : '↓'} ${t('budget', '{percent}% vs last month', { percent: Math.abs(change).toFixed(1) })}`; + incomeChangeEl.className = `hero-change ${change >= 0 ? 'positive' : 'negative'}`; } } @@ -417,17 +425,14 @@ export default class DashboardModule { // Calculate month-over-month change for expenses const expensesChangeEl = document.getElementById('hero-expenses-change'); - if (expensesChangeEl && summary.trends && summary.trends.expenses) { - const expenseData = summary.trends.expenses; - if (expenseData.length >= 2) { - const currentMonth = expenseData[expenseData.length - 1] || 0; - const lastMonth = expenseData[expenseData.length - 2] || 0; - const change = lastMonth > 0 ? ((currentMonth - lastMonth) / lastMonth * 100) : 0; - if (change !== 0) { - // For expenses, down is good - expensesChangeEl.innerHTML = `${change >= 0 ? '↑' : '↓'} ${t('budget', '{percent}% vs last month', { percent: Math.abs(change).toFixed(1) })}`; - expensesChangeEl.className = `hero-change ${change <= 0 ? 'positive' : 'negative'}`; - } + if (expensesChangeEl && previous) { + const currentMonth = totals.totalExpenses || 0; + const lastMonth = previous.totalExpenses || 0; + const change = lastMonth > 0 ? ((currentMonth - lastMonth) / lastMonth * 100) : 0; + if (change !== 0) { + // For expenses, down is good + expensesChangeEl.innerHTML = `${change >= 0 ? '↑' : '↓'} ${t('budget', '{percent}% vs last month', { percent: Math.abs(change).toFixed(1) })}`; + expensesChangeEl.className = `hero-change ${change <= 0 ? 'positive' : 'negative'}`; } } @@ -508,18 +513,14 @@ export default class DashboardModule { el.className = `hero-value ${cashFlow >= 0 ? 'income' : 'expenses'}`; const changeEl = document.getElementById('hero-cash-flow-change'); - if (changeEl && summary.trends) { - // Calculate month-over-month change - const incomeData = summary.trends.income || []; - const expenseData = summary.trends.expenses || []; - if (incomeData.length >= 2 && expenseData.length >= 2) { - const currentCF = (incomeData[incomeData.length - 1] || 0) - (expenseData[expenseData.length - 1] || 0); - const lastCF = (incomeData[incomeData.length - 2] || 0) - (expenseData[expenseData.length - 2] || 0); - const change = lastCF !== 0 ? ((currentCF - lastCF) / Math.abs(lastCF) * 100) : 0; - if (change !== 0) { - changeEl.innerHTML = `${change >= 0 ? '↑' : '↓'} ${t('budget', '{percent}% vs last month', { percent: Math.abs(change).toFixed(1) })}`; - changeEl.className = `hero-change ${change >= 0 ? 'positive' : 'negative'}`; - } + const previous = summary.previousTotals; + if (changeEl && previous) { + // This budget period against the whole one before + const lastCF = (previous.totalIncome || 0) - (previous.totalExpenses || 0); + const change = lastCF !== 0 ? ((cashFlow - lastCF) / Math.abs(lastCF) * 100) : 0; + if (change !== 0) { + changeEl.innerHTML = `${change >= 0 ? '↑' : '↓'} ${t('budget', '{percent}% vs last month', { percent: Math.abs(change).toFixed(1) })}`; + changeEl.className = `hero-change ${change >= 0 ? 'positive' : 'negative'}`; } } } @@ -3041,6 +3042,11 @@ export default class DashboardModule { this.charts[instanceId].destroy(); } + const card = this.isDuplicateInstance(instanceId) + ? this.getWidgetCard(instanceId) + : document.getElementById('trend-chart-card'); + formatters.showMonthGroupingNote(card?.querySelector('.month-grouping-note'), trends?.monthStartDay || 1); + if (!trends || !trends.labels || trends.labels.length === 0) return; const ctx = canvas.getContext('2d'); @@ -3055,8 +3061,9 @@ export default class DashboardModule { // user's language; `labels` is its English "M Y", kept for older servers. const months = Array.isArray(trends.months) && trends.months.length === trends.labels.length ? trends.months : null; const labels = months ? months.map(m => formatters.formatYearMonth(m)) : trends.labels; - const now = new Date(); - const thisMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; + // With budget months (#457) the month in progress is the budget month + const monthStartDay = trends.monthStartDay || 1; + const thisMonth = formatters.currentBudgetMonth(monthStartDay); const lastIndex = trends.labels.length - 1; const partialIndex = months && months[lastIndex] === thisMonth ? lastIndex : -1; const partialSegment = { @@ -3669,10 +3676,19 @@ export default class DashboardModule { async refreshTrendChart(months, accountId = null, instanceId = 'trendChart') { try { - const startDate = new Date(); - startDate.setMonth(startDate.getMonth() - months); + const chartStartDay = formatters.reportMonthStartDay(this.settings); + let startDate; + if (chartStartDay > 1) { + // Whole budget months (#457), as the dashboard loads them + const chartMonth = formatters.currentBudgetMonth(chartStartDay); + startDate = formatters.budgetMonthRange(formatters.shiftMonth(chartMonth, -months), chartStartDay).start; + } else { + const start = new Date(); + start.setMonth(start.getMonth() - months); + startDate = formatters.formatDateForAPI(start); + } - let url = `/apps/budget/api/reports/summary?startDate=${formatters.formatDateForAPI(startDate)}`; + let url = `/apps/budget/api/reports/summary?startDate=${startDate}`; if (accountId) { url += `&accountId=${accountId}`; } diff --git a/budget/src/modules/reports/ReportsModule.js b/budget/src/modules/reports/ReportsModule.js index 42be2a0f3..2e7b53d83 100644 --- a/budget/src/modules/reports/ReportsModule.js +++ b/budget/src/modules/reports/ReportsModule.js @@ -744,6 +744,7 @@ export default class ReportsModule { renderReportTrendChart(trends) { const canvas = document.getElementById('report-trend-chart'); if (!canvas || !trends) return; + formatters.showMonthGroupingNote(document.getElementById('report-trend-month-note'), trends.monthStartDay || 1); if (this.reportCharts.trend) { this.reportCharts.trend.destroy(); @@ -1052,6 +1053,7 @@ export default class ReportsModule { if (avgNetEl) avgNetEl.textContent = this.formatCurrency(averages.net || 0, currency); // Render chart + formatters.showMonthGroupingNote(document.getElementById('report-cashflow-month-note'), data.period?.monthStartDay || 1); this.renderCashFlowChart(data.data || []); // Render table @@ -1069,6 +1071,7 @@ export default class ReportsModule { const sortSel = document.getElementById('category-monthly-sort'); if (sortSel && sortSel.value !== sort) sortSel.value = sort; + formatters.showMonthGroupingNote(document.getElementById('category-monthly-month-note'), data.period?.monthStartDay || 1); this.renderCategoryMonthlyMatrix(data); } diff --git a/budget/src/utils/formatters.js b/budget/src/utils/formatters.js index 24d3cb550..4d7a1f64b 100644 --- a/budget/src/utils/formatters.js +++ b/budget/src/utils/formatters.js @@ -554,6 +554,36 @@ export function currentBudgetMonth(startDay = 1, referenceDate = null) { return budgetMonthForCycle(cycle.start, cycle.end); } +/** + * The day the months of the dashboard charts and the month-by-month reports + * start on: the budget start day when the user chose budget months for them + * in Settings, otherwise 1, calendar months (#457). As + * BudgetCarryoverService::reportMonthStartDay on the server. + * + * @param {object} settings - The user's settings + * @returns {number} 1-31 + */ +export function reportMonthStartDay(settings) { + if (settings?.report_month_grouping !== 'budget') return 1; + const startDay = parseInt(settings?.budget_start_day || '1', 10); + return Number.isFinite(startDay) ? Math.min(31, Math.max(1, startDay)) : 1; +} + +/** + * Show or hide a note saying a chart's or report's months are budget months. + * + * @param {HTMLElement|null} el - The note element + * @param {number} monthStartDay - As reportMonthStartDay(); 1 hides the note + */ +export function showMonthGroupingNote(el, monthStartDay) { + if (!el) return; + const budgetMonths = monthStartDay > 1; + el.textContent = budgetMonths + ? t('budget', 'Months are budget months starting on day {day}, as chosen in Settings.', { day: monthStartDay }) + : ''; + el.style.display = budgetMonths ? '' : 'none'; +} + /** * The dates a budget month covers: the calendar month, or with a budget start * day the cycle holding the month's 15th. diff --git a/budget/templates/index.php b/budget/templates/index.php index d0f96e7e2..8f0381c52 100644 --- a/budget/templates/index.php +++ b/budget/templates/index.php @@ -605,6 +605,7 @@ class="app-navigation-search-clear icon-close"
+ @@ -3324,6 +3325,7 @@ class="app-navigation-search-clear icon-close"
+ @@ -3487,6 +3489,7 @@ class="app-navigation-search-clear icon-close"
+ @@ -3709,6 +3712,7 @@ class="app-navigation-search-clear icon-close"

t('Income is shown positive, expenses negative. Parent categories include the totals of their sub-categories.')); ?>

+
@@ -4465,6 +4469,17 @@ class="app-navigation-search-clear icon-close" min="1" max="31" step="1" value="1"> +
+ + +
+
@@ -3161,6 +3162,7 @@ class="app-navigation-search-clear icon-close" -- -- + diff --git a/budget/tests/Unit/Service/Forecast/PatternAnalyzerTest.php b/budget/tests/Unit/Service/Forecast/PatternAnalyzerTest.php index c94a41462..53a1645c3 100644 --- a/budget/tests/Unit/Service/Forecast/PatternAnalyzerTest.php +++ b/budget/tests/Unit/Service/Forecast/PatternAnalyzerTest.php @@ -173,6 +173,26 @@ public function testAggregateMonthlyDataGroupsByMonth(): void { $this->assertEquals(800.0, $result[1]['expenses']); } + public function testAggregateMonthlyDataGroupsByBudgetMonthWithAStartDay(): void { + // Start day 26: each salary opens its own budget month with the + // spending that follows it; by calendar month they would pair the + // September salary with October's rent + $transactions = [ + $this->makeTransaction('2026-09-26', 3000, 'credit'), + $this->makeTransaction('2026-10-01', 900, 'debit'), + $this->makeTransaction('2026-10-26', 3000, 'credit'), + $this->makeTransaction('2026-11-01', 950, 'debit'), + ]; + + $result = $this->analyzer->aggregateMonthlyData($transactions, [], 26); + + $this->assertCount(2, $result); + $this->assertEquals(3000.0, $result[0]['income']); + $this->assertEquals(900.0, $result[0]['expenses']); + $this->assertEquals(3000.0, $result[1]['income']); + $this->assertEquals(950.0, $result[1]['expenses']); + } + public function testAggregateMonthlyDataSortsByMonth(): void { $transactions = [ $this->makeTransaction('2025-03-01', 100, 'debit'), diff --git a/budget/tests/Unit/Service/ForecastServiceTest.php b/budget/tests/Unit/Service/ForecastServiceTest.php index 209e0faad..914e944ed 100644 --- a/budget/tests/Unit/Service/ForecastServiceTest.php +++ b/budget/tests/Unit/Service/ForecastServiceTest.php @@ -8,11 +8,13 @@ use OCA\Budget\Db\AccountMapper; use OCA\Budget\Db\Transaction; use OCA\Budget\Db\TransactionMapper; +use OCA\Budget\Service\BudgetCarryoverService; use OCA\Budget\Service\Forecast\ForecastProjector; use OCA\Budget\Service\Forecast\PatternAnalyzer; use OCA\Budget\Service\Forecast\ScenarioBuilder; use OCA\Budget\Service\Forecast\TrendCalculator; use OCA\Budget\Service\ForecastService; +use OCA\Budget\Service\UserClock; use OCP\ICache; use OCP\ICacheFactory; use PHPUnit\Framework\TestCase; @@ -363,4 +365,127 @@ public function testServiceWorksWithoutCacheFactory(): void { $serviceNoCache->invalidateCache('user1'); $this->assertTrue(true); // No exception thrown } + + // ===== Budget months and complete history (#457) ===== + + /** + * A forecast on 30 October 2026, counting in months that start on + * $monthStartDay, with what each step was asked recorded in $seen. + * + * @param Account[] $accounts + * @param array $firstDates + */ + private function forecastOn30October(int $monthStartDay, array $accounts, array $firstDates, ?array &$seen): array { + $seen = ['range' => null, 'firstDatesFor' => null, 'aggregatedWith' => null]; + $clock = $this->createMock(UserClock::class); + $clock->method('today')->willReturn('2026-10-30'); + $carryover = $this->createMock(BudgetCarryoverService::class); + $carryover->method('reportMonthStartDay')->willReturn($monthStartDay); + $this->cache->method('get')->willReturn(null); + $this->accountMapper->method('findAll')->willReturn($accounts); + $this->transactionMapper->method('getFirstTransactionDates')->willReturnCallback(function (array $ids) use (&$seen, $firstDates) { + $seen['firstDatesFor'] = $ids; + return array_intersect_key($firstDates, array_flip($ids)); + }); + $this->transactionMapper->method('findAllByUserAndDateRange')->willReturnCallback(function ($u, $start, $end) use (&$seen) { + $seen['range'] = [$start, $end]; + return []; + }); + $this->patternAnalyzer->method('aggregateMonthlyData')->willReturnCallback(function ($t, $rates, $day) use (&$seen) { + $seen['aggregatedWith'] = $day; + return []; + }); + $this->patternAnalyzer->method('getCategoryBreakdown')->willReturn([]); + $this->trendCalculator->method('getTrendDirection')->willReturn('stable'); + + $service = new ForecastService( + $this->accountMapper, $this->transactionMapper, $this->patternAnalyzer, $this->trendCalculator, + $this->scenarioBuilder, $this->projector, null, $clock, null, null, null, $carryover + ); + return $service->getLiveForecast('user1', 3); + } + + private function accountWithOpeningBalance(int $id, float $opening): Account { + $account = $this->makeAccount($id, 1000.0, 'EUR'); + $account->setOpeningBalance($opening); + return $account; + } + + public function testLiveForecastLearnsFromCalendarMonthsByDefault(): void { + $result = $this->forecastOn30October(1, [$this->accountWithOpeningBalance(1, 0.0)], [], $seen); + + $this->assertSame(['2025-10-01', '2026-09-30'], $seen['range']); + $this->assertSame(1, $seen['aggregatedWith']); + $this->assertSame(['2026-11', '2026-12', '2027-01'], array_column($result['monthlyProjections'], 'yearMonth')); + $this->assertSame(1, $result['monthStartDay']); + } + + public function testLiveForecastLearnsFromBudgetMonthsWhenChosen(): void { + // 30 October with start day 26 is in November's budget month + $result = $this->forecastOn30October(26, [$this->accountWithOpeningBalance(1, 0.0)], [], $seen); + + $this->assertSame(['2025-10-26', '2026-10-25'], $seen['range']); + $this->assertSame(26, $seen['aggregatedWith']); + $this->assertSame(['2026-12', '2027-01', '2027-02'], array_column($result['monthlyProjections'], 'yearMonth')); + $this->assertSame(26, $result['monthStartDay']); + } + + public function testLiveForecastLeavesOutAMonthTheHistoryOnlyPartlyHolds(): void { + // Synced from 6 September: September holds only part of its month + $this->forecastOn30October(26, [$this->accountWithOpeningBalance(1, 2500.0)], [1 => '2026-09-06'], $seen); + + // Budget months: September (26 Aug - 25 Sep) is partial, October whole + $this->assertSame(['2026-09-26', '2026-10-25'], $seen['range']); + } + + public function testLiveForecastHasNoHistoryWhenNoMonthIsComplete(): void { + $result = $this->forecastOn30October(1, [$this->accountWithOpeningBalance(1, 2500.0)], [1 => '2026-09-06'], $seen); + + // Calendar months: September is partial and October still running + $this->assertNull($seen['range']); + $this->assertSame(0, $result['dataQuality']['monthsOfData']); + } + + public function testLiveForecastKeepsAMonthTheHistoryStartsOnTheFirstOf(): void { + $this->forecastOn30October(1, [$this->accountWithOpeningBalance(1, 2500.0)], [1 => '2026-08-01'], $seen); + + $this->assertSame(['2026-08-01', '2026-09-30'], $seen['range']); + } + + public function testLiveForecastKeepsAMonthWhoseFirstDaysJustHadNoBookings(): void { + // History from 28 September, two days into October's budget month + $this->forecastOn30October(26, [$this->accountWithOpeningBalance(1, 2500.0)], [1 => '2026-09-28'], $seen); + + $this->assertSame(['2026-09-26', '2026-10-25'], $seen['range']); + } + + public function testLiveForecastIsBoundedByTheLatestHistoryOfAnyAccount(): void { + $this->forecastOn30October(1, [ + $this->accountWithOpeningBalance(1, 2500.0), + $this->accountWithOpeningBalance(2, 300.0), + ], [1 => '2026-07-30', 2 => '2026-08-10'], $seen); + + $this->assertSame(['2026-09-01', '2026-09-30'], $seen['range']); + } + + public function testLiveForecastIgnoresAccountsOpenedFromNothing(): void { + // No opening balance: nothing before its first transaction is missing + $this->forecastOn30October(1, [ + $this->accountWithOpeningBalance(1, 0.0), + $this->accountWithOpeningBalance(2, 2500.0), + ], [1 => '2026-09-06', 2 => '2025-01-01'], $seen); + + $this->assertSame([2], $seen['firstDatesFor']); + $this->assertSame(['2025-10-01', '2026-09-30'], $seen['range']); + } + + public function testLiveForecastIgnoresAnAccountAddedAfterTheHistory(): void { + // Added this month: it would otherwise leave no month at all + $this->forecastOn30October(1, [ + $this->accountWithOpeningBalance(1, 2500.0), + $this->accountWithOpeningBalance(2, 300.0), + ], [1 => '2025-01-01', 2 => '2026-10-12'], $seen); + + $this->assertSame(['2025-10-01', '2026-09-30'], $seen['range']); + } } diff --git a/budget/tests/js/reportMonthGrouping.test.js b/budget/tests/js/reportMonthGrouping.test.js index fd17febc8..21da310b5 100644 --- a/budget/tests/js/reportMonthGrouping.test.js +++ b/budget/tests/js/reportMonthGrouping.test.js @@ -15,6 +15,7 @@ vi.mock('@nextcloud/l10n', () => ({ import DashboardModule from '../../src/modules/dashboard/DashboardModule.js'; import ReportsModule from '../../src/modules/reports/ReportsModule.js'; +import ForecastModule from '../../src/modules/forecast/ForecastModule.js'; import { reportMonthStartDay, showMonthGroupingNote } from '../../src/utils/formatters.js'; function makeDashboard(settings = {}) { @@ -169,3 +170,17 @@ describe('report period presets', () => { expect(rangeFor('last-year', budget)).toEqual(['2024-12-26', '2025-12-25']); }); }); + +describe('forecast', () => { + it('says when it learns from budget months', () => { + document.body.innerHTML = ''; + const forecast = Object.create(ForecastModule.prototype); + const data = { confidence: 40, monthStartDay: 26, dataQuality: { monthsOfData: 1, transactionCount: 88 } }; + + forecast.displayDataQuality(data); + expect(document.getElementById('forecast-month-note').textContent).toContain('day 26'); + + forecast.displayDataQuality({ ...data, monthStartDay: 1 }); + expect(document.getElementById('forecast-month-note').style.display).toBe('none'); + }); +});