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/TransactionMapper.php b/budget/lib/Db/TransactionMapper.php index df9583d3a..bd2fd6368 100644 --- a/budget/lib/Db/TransactionMapper.php +++ b/budget/lib/Db/TransactionMapper.php @@ -457,6 +457,33 @@ public function findAllByUserAndDateRange(string $userId, string $startDate, str return $this->findEntities($qb); } + /** + * The date of each account's first transaction (Y-m-d), keyed by account + * id. Accounts without transactions are left out. The forecast reads it + * to know from when an account's history is complete. + * + * @param int[] $accountIds + * @return array + */ + public function getFirstTransactionDates(array $accountIds): array { + if ($accountIds === []) { + return []; + } + $qb = $this->db->getQueryBuilder(); + $qb->select('account_id') + ->selectAlias($qb->func()->min('date'), 'first_date') + ->from($this->getTableName()) + ->where($qb->expr()->in('account_id', $qb->createNamedParameter(array_map('intval', $accountIds), IQueryBuilder::PARAM_INT_ARRAY))) + ->groupBy('account_id'); + $result = $qb->executeQuery(); + $dates = []; + while ($row = $result->fetch()) { + $dates[(int)$row['account_id']] = substr((string)$row['first_date'], 0, 10); + } + $result->closeCursor(); + return $dates; + } + /** * Whether any transaction or split part, in anyone's account, uses this * category. Deliberately unscoped: a shared category can be used from the 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/Forecast/PatternAnalyzer.php b/budget/lib/Service/Forecast/PatternAnalyzer.php index e025a2d54..11f90faed 100644 --- a/budget/lib/Service/Forecast/PatternAnalyzer.php +++ b/budget/lib/Service/Forecast/PatternAnalyzer.php @@ -6,6 +6,7 @@ use OCA\Budget\Db\CategoryMapper; use OCA\Budget\Db\TransactionSplitMapper; +use OCA\Budget\Service\BudgetPeriod; use OCA\Budget\Service\MoneyCalculator; /** @@ -173,13 +174,15 @@ public static function inForecastCurrency(float $amount, $transaction, array $ac * @param array $accountRates account id => multiplier into the * forecast's currency (CurrencyTotals::accountRates()); empty * when the accounts share one currency + * @param int $monthStartDay 1 for calendar months, else the day budget + * months start on (#457) * @return array Monthly income/expense totals */ - public function aggregateMonthlyData(array $transactions, array $accountRates = []): array { + public function aggregateMonthlyData(array $transactions, array $accountRates = [], int $monthStartDay = 1): array { $monthlyData = []; foreach ($transactions as $transaction) { - $month = date('Y-m', strtotime($transaction->getDate())); + $month = BudgetPeriod::monthContaining(substr((string)$transaction->getDate(), 0, 10), $monthStartDay); if (!isset($monthlyData[$month])) { $monthlyData[$month] = ['income' => 0.0, 'expenses' => 0.0]; diff --git a/budget/lib/Service/ForecastService.php b/budget/lib/Service/ForecastService.php index 38c4746f3..8046f1a77 100644 --- a/budget/lib/Service/ForecastService.php +++ b/budget/lib/Service/ForecastService.php @@ -32,6 +32,13 @@ class ForecastService { */ private const MIN_TREND_MONTHS = 3; + /** + * Days a month may have passed before an account's first transaction and + * still count as complete (completeHistoryStart()): a weekend and a + * holiday without bookings, not a bank sync that began mid-month. + */ + private const HISTORY_START_GRACE_DAYS = 3; + private AccountMapper $accountMapper; private TransactionMapper $transactionMapper; private PatternAnalyzer $patternAnalyzer; @@ -52,6 +59,7 @@ public function __construct( private ?CurrencyTotals $currencyTotals = null, private ?RecurringIncomeMapper $incomeMapper = null, private ?BillMapper $billMapper = null, + private ?BudgetCarryoverService $carryoverService = null, ) { $this->accountMapper = $accountMapper; $this->transactionMapper = $transactionMapper; @@ -242,7 +250,10 @@ public function getLiveForecast(string $userId, int $forecastMonths = 6, ?array // Key includes account visibility: a share-restricted viewer and the // owner must never share a cache entry (the owner's totals would leak // across the visibility boundary for up to the TTL). - $cacheKey = "live_{$userId}_{$forecastMonths}_" . md5(json_encode($visibleAccountIds ?? [])); + // The months it counts in are part of the key too, so a change of + // the setting shows at once (#457) + $monthStartDay = $this->carryoverService?->reportMonthStartDay($userId) ?? 1; + $cacheKey = "live_{$userId}_{$forecastMonths}_{$monthStartDay}_" . md5(json_encode($visibleAccountIds ?? [])); // Try to get from cache if ($this->cache !== null) { @@ -304,11 +315,16 @@ public function getLiveForecast(string $userId, int $forecastMonths = 6, ?array // History: the twelve complete months before this one. The month in // progress is not a month of income and spending yet; counted as one, // four days of October read as a month in which almost nothing came - // in, and the trend through it projected no income at all. - $thisMonth = new \DateTimeImmutable(substr($today, 0, 8) . '01'); - $endDate = $thisMonth->modify('-1 day')->format('Y-m-d'); - $startDate = $thisMonth->modify('-12 months')->format('Y-m-d'); - $transactions = $this->transactionMapper->findAllByUserAndDateRange($userId, $startDate, $endDate, null, $visibleAccountIds); + // in, and the trend through it projected no income at all. Calendar + // months, or budget months when the user counts in them (#457). + $thisMonth = BudgetPeriod::monthContaining($today, $monthStartDay); + $startDate = BudgetPeriod::range(self::shiftMonth($thisMonth, -12), $monthStartDay)[0]; + $endDate = BudgetPeriod::range(self::shiftMonth($thisMonth, -1), $monthStartDay)[1]; + // Nor is a month the accounts' history only reaches part of + $startDate = max($startDate, $this->completeHistoryStart($accounts, $monthStartDay, $endDate)); + $transactions = $startDate <= $endDate + ? $this->transactionMapper->findAllByUserAndDateRange($userId, $startDate, $endDate, null, $visibleAccountIds) + : []; if ($rates !== null) { // The history of an account left out above stays out with it $transactions = array_values(array_filter( @@ -325,7 +341,7 @@ public function getLiveForecast(string $userId, int $forecastMonths = 6, ?array $transactions = PatternAnalyzer::withoutInternalTransfers($transactions); // Analyze patterns, each amount in the forecast's currency - $monthlyData = $this->patternAnalyzer->aggregateMonthlyData($transactions, $rates ?? []); + $monthlyData = $this->patternAnalyzer->aggregateMonthlyData($transactions, $rates ?? [], $monthStartDay); $months = count($monthlyData); // Calculate averages and trends @@ -373,8 +389,8 @@ public function getLiveForecast(string $userId, int $forecastMonths = 6, ?array // Months after the user's own, stepped from the 1st: from the 31st, // "+1 month" skipped any shorter month for ($i = 1; $i <= $forecastMonths; $i++) { - $projectionDate = $thisMonth->modify("+{$i} months"); - $monthLabel = $projectionDate->format('M Y'); + $projectionMonth = self::shiftMonth($thisMonth, $i); + $monthLabel = \DateTimeImmutable::createFromFormat('!Y-m', $projectionMonth)->format('M Y'); $projectedIncome = max(0, $recurringIncome, $avgIncome + ($incomeTrend * $i)); $projectedExpenses = max(0, $spendingFloor, $avgExpenses + ($projectedExpenseTrend * $i)); @@ -389,7 +405,7 @@ public function getLiveForecast(string $userId, int $forecastMonths = 6, ?array // own language; `month` stays the English "M Y" label for the // forecast-warning notification and anything else reading it. 'month' => $monthLabel, - 'yearMonth' => $projectionDate->format('Y-m'), + 'yearMonth' => $projectionMonth, 'balance' => round($projectedBalance, 2), 'income' => round($projectedIncome, 2), 'expenses' => round($projectedExpenses, 2), @@ -423,6 +439,8 @@ public function getLiveForecast(string $userId, int $forecastMonths = 6, ?array ], 'categoryBreakdown' => $categoryBreakdown, 'confidence' => round($confidence, 0), + // 1 for calendar months, else the day budget months start on + 'monthStartDay' => $monthStartDay, 'dataQuality' => [ 'monthsOfData' => $months, 'transactionCount' => $transactionCount, @@ -438,6 +456,54 @@ public function getLiveForecast(string $userId, int $forecastMonths = 6, ?array return $result; } + /** + * A month (Y-m) shifted by whole months, stepped from the 1st. + */ + private static function shiftMonth(string $month, int $offset): string { + return \DateTimeImmutable::createFromFormat('!Y-m', $month)->modify("{$offset} months")->format('Y-m'); + } + + /** + * The first day of the first month whose history every account holds + * in full, or '' when nothing bounds it. + * + * An account that had money in it before its first transaction (an + * opening balance) existed before Budget knew it, so the months before + * that transaction are missing, not empty. Bank sync is the usual case: + * it fetches the last 90 days, so the first month holds only its last + * few days. Counted as a whole month, five days of September with the + * salary in them and almost no spending made the forecast's average + * savings several times too high. An account opened from nothing has + * no history to miss and bounds nothing, and nor does one whose history + * starts after $historyEnd: added this month, it would otherwise leave + * the forecast no month at all until a month of its own had passed. + * + * @param \OCA\Budget\Db\Account[] $accounts + */ + private function completeHistoryStart(array $accounts, int $monthStartDay, string $historyEnd): string { + $withPast = array_values(array_map( + static fn ($a) => (int)$a->getId(), + array_filter($accounts, static fn ($a) => abs((float)($a->getOpeningBalance() ?? 0)) >= 0.005) + )); + $firstDates = array_filter( + $this->transactionMapper->getFirstTransactionDates($withPast), + static fn (string $date) => $date <= $historyEnd + ); + if ($firstDates === []) { + return ''; + } + $latest = max($firstDates); + $month = BudgetPeriod::monthContaining($latest, $monthStartDay); + $monthStart = BudgetPeriod::range($month, $monthStartDay)[0]; + // A first transaction is not where the history starts: the days + // before it may just have had none, a weekend after the 1st. A + // month whose first few days are all that could be missing counts. + $daysIn = (int)(new \DateTimeImmutable($monthStart))->diff(new \DateTimeImmutable($latest))->days; + return $daysIn <= self::HISTORY_START_GRACE_DAYS + ? $monthStart + : BudgetPeriod::range(self::shiftMonth($month, 1), $monthStartDay)[0]; + } + /** * Remove transactions flagged as excluded-from-forecast (#270). * Re-indexes the array so downstream consumers can rely on sequential keys. diff --git a/budget/lib/Service/Report/ReportAggregator.php b/budget/lib/Service/Report/ReportAggregator.php index cf0da33b3..308dcd9f7 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,30 @@ public function getCategoryMonthlyReport( } /** - * List of 'YYYY-MM' month keys from startDate to endDate inclusive. + * The months of a trend series: every month the range touches, calendar + * months or with a month start day budget months. Stepping a month at a + * time from the start date lost the last month of a range starting + * later in the month than it ends (26 Jul - 25 Oct had no October), and + * skipped February from the 31st of January. + * + * @return string[] 'YYYY-MM' keys + */ + private function trendMonths(string $startDate, string $endDate, int $monthStartDay): array { + return BudgetPeriod::monthsBetween($startDate, $endDate, $monthStartDay); + } + + /** + * 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 +1116,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/forecast/ForecastModule.js b/budget/src/modules/forecast/ForecastModule.js index 0f7850dd7..c9ea1fb95 100644 --- a/budget/src/modules/forecast/ForecastModule.js +++ b/budget/src/modules/forecast/ForecastModule.js @@ -384,5 +384,7 @@ export default class ForecastModule { const quality = data.dataQuality; dataInfoEl.textContent = t('budget', 'Based on {months} month(s) of data ({count} transactions)', { months: quality.monthsOfData, count: quality.transactionCount }); } + + formatters.showMonthGroupingNote(document.getElementById('forecast-month-note'), data.monthStartDay || 1); } } diff --git a/budget/src/modules/reports/ReportsModule.js b/budget/src/modules/reports/ReportsModule.js index 42be2a0f3..d73d8312e 100644 --- a/budget/src/modules/reports/ReportsModule.js +++ b/budget/src/modules/reports/ReportsModule.js @@ -127,6 +127,12 @@ export default class ReportsModule { } setReportDateRange(preset) { + const monthStartDay = formatters.reportMonthStartDay(this.settings); + if (monthStartDay > 1) { + this.setBudgetMonthReportDateRange(preset, monthStartDay); + return; + } + const now = new Date(); let startDate, endDate; @@ -156,6 +162,46 @@ export default class ReportsModule { setDateValue('report-end-date', formatters.formatDateForAPI(endDate)); } + /** + * The presets in whole budget months, when the user chose budget months + * for the reports (#457). "This Month" as a calendar month would span + * two budget months: with start day 26, 1-25 October is October's and + * 26-31 October already November's. + * + * @param {string} preset - The period selector's value + * @param {number} startDay - The budget start day (2-31) + */ + setBudgetMonthReportDateRange(preset, startDay) { + const today = formatters.formatDateForAPI(new Date()); + const current = formatters.currentBudgetMonth(startDay); + const year = parseInt(current.slice(0, 4), 10); + const range = (first, last) => [ + formatters.budgetMonthRange(first, startDay).start, + last === null ? today : formatters.budgetMonthRange(last, startDay).end, + ]; + + let startDate, endDate; + switch (preset) { + case 'this-month': + [startDate, endDate] = range(current, current); + break; + case 'last-3-months': + [startDate, endDate] = range(formatters.shiftMonth(current, -2), current); + break; + case 'ytd': + [startDate, endDate] = range(`${year}-01`, null); + break; + case 'last-year': + [startDate, endDate] = range(`${year - 1}-01`, `${year - 1}-12`); + break; + default: + [startDate, endDate] = range(formatters.shiftMonth(current, -2), null); + } + + setDateValue('report-start-date', startDate); + setDateValue('report-end-date', endDate); + } + async loadReportsView() { // Setup event listeners on first load if (!this.reportEventListenersSetup) { @@ -744,6 +790,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 +1099,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 +1117,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..2e7eb823b 100644 --- a/budget/templates/index.php +++ b/budget/templates/index.php @@ -605,6 +605,7 @@ class="app-navigation-search-clear icon-close"
+ @@ -3076,6 +3077,7 @@ class="app-navigation-search-clear icon-close"
📊

t('Not Enough Data')); ?>

t('Add more transactions to generate an accurate forecast. We need at least 1 month of transaction history.')); ?>

+

t('Only complete months count: a month your accounts\' history only partly covers, such as the first month a bank sync fetched, is left out.')); ?>

@@ -3160,6 +3162,7 @@ class="app-navigation-search-clear icon-close" -- -- + @@ -3324,6 +3327,7 @@ class="app-navigation-search-clear icon-close"
+ @@ -3487,6 +3491,7 @@ class="app-navigation-search-clear icon-close"
+ @@ -3709,6 +3714,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 +4471,17 @@ class="app-navigation-search-clear icon-close" min="1" max="31" step="1" value="1"> +
+ + +
+