Fix Profit Analysis using all-time expenses for year/quarter filters

The Profit Analysis expense filter only handled month and rolling
presets, so selecting a year (e.g. 2026) fell through to 'return true'
and counted ALL expenses against that year's revenue — producing a wrong
(often negative) profit that disagreed with the Analytics page.

Unify date filtering: new ProfitAnalysisService.isInDateRange is the
single matcher for months, quarters, years, custom ranges, and presets;
filterOrdersByDateRange and the new filterExpensesByDateRange both use
it, and Profit Analysis now filters expenses through the service so
orders and expenses always share the same range.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-06-13 18:20:44 +10:00
parent a789f01bb4
commit 71a01e92ff
2 changed files with 57 additions and 81 deletions

View file

@ -29,35 +29,9 @@ const ProfitAnalysis = () => {
return ProfitAnalysisService.filterOrdersByDateRange(orders || [], dateRange); return ProfitAnalysisService.filterOrdersByDateRange(orders || [], dateRange);
}, [orders, dateRange]); }, [orders, dateRange]);
// Filter expenses by the same date range // Filter expenses by the same date range as orders (shared logic)
const filteredExpenses = useMemo(() => { const filteredExpenses = useMemo(() => {
if (!expenses || dateRange === 'all') return expenses || []; return ProfitAnalysisService.filterExpensesByDateRange(expenses || [], dateRange);
const now = new Date();
return expenses.filter(expense => {
const expenseDate = new Date(expense.date);
// Handle specific month format (e.g., "2025-07" for July 2025)
if (dateRange.match(/^\d{4}-\d{2}$/)) {
const [year, month] = dateRange.split('-');
return expenseDate.getFullYear() === parseInt(year) &&
expenseDate.getMonth() === parseInt(month) - 1;
}
// Handle preset ranges
switch (dateRange) {
case 'week':
return expenseDate >= new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
case 'month':
return expenseDate >= new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
case 'quarter':
return expenseDate >= new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
case 'year':
return expenseDate >= new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
default:
return true;
}
});
}, [expenses, dateRange]); }, [expenses, dateRange]);
// Calculate profit metrics using the service with expenses // Calculate profit metrics using the service with expenses

View file

@ -258,65 +258,67 @@ export class ProfitAnalysisService {
return results; return results;
} }
/**
* Whether a date falls within a date-range token. Single source of truth so
* orders and expenses are always filtered identically (months, quarters,
* years, custom ranges, and legacy presets).
*/
static isInDateRange(dateValue: string | Date, dateRange: string): boolean {
if (dateRange === 'all') return true;
const date = new Date(dateValue);
// Specific month format (e.g., "2025-07")
if (dateRange.match(/^\d{4}-\d{2}$/)) {
const [year, month] = dateRange.split('-');
return date.getFullYear() === parseInt(year) && date.getMonth() === parseInt(month) - 1;
}
// Quarter format (e.g., "2025-Q1")
if (dateRange.match(/^\d{4}-Q[1-4]$/)) {
const [year, quarter] = dateRange.split('-Q');
return date.getFullYear() === parseInt(year) &&
Math.floor(date.getMonth() / 3) + 1 === parseInt(quarter);
}
// Year format (e.g., "2025")
if (dateRange.match(/^\d{4}$/)) {
return date.getFullYear() === parseInt(dateRange);
}
// Custom range (e.g., "2025-01-01_2025-12-31")
if (dateRange.includes('_')) {
const [startDate, endDate] = dateRange.split('_');
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
return date >= new Date(startDate) && date <= end;
}
// Legacy preset ranges
const daysDiff = (Date.now() - date.getTime()) / (24 * 60 * 60 * 1000);
switch (dateRange) {
case 'week': return daysDiff >= 0 && daysDiff <= 7;
case 'month': return daysDiff >= 0 && daysDiff <= 30;
case 'quarter': return daysDiff >= 0 && daysDiff <= 90;
case 'year': return daysDiff >= 0 && daysDiff <= 365;
default: return true;
}
}
/** /**
* Filter orders by date range * Filter orders by date range
*/ */
static filterOrdersByDateRange(orders: Order[], dateRange: string): Order[] { static filterOrdersByDateRange(orders: Order[], dateRange: string): Order[] {
if (dateRange === 'all' || !orders) return orders; if (dateRange === 'all' || !orders) return orders;
return orders.filter(order => this.isInDateRange(order.dateOrdered, dateRange));
}
const now = new Date(); /**
const filteredOrders = orders.filter(order => { * Filter expenses by date range (same logic as orders)
const orderDate = new Date(order.dateOrdered); */
static filterExpensesByDateRange(expenses: Expense[], dateRange: string): Expense[] {
// Handle specific month format (e.g., "2025-07" for July 2025) if (dateRange === 'all' || !expenses) return expenses;
if (dateRange.match(/^\d{4}-\d{2}$/)) { return expenses.filter(expense => this.isInDateRange(expense.date, dateRange));
const [year, month] = dateRange.split('-');
return orderDate.getFullYear() === parseInt(year) &&
orderDate.getMonth() === parseInt(month) - 1;
}
// Handle quarter format (e.g., "2025-Q1")
if (dateRange.match(/^\d{4}-Q[1-4]$/)) {
const [year, quarter] = dateRange.split('-Q');
const targetYear = parseInt(year);
const targetQuarter = parseInt(quarter);
const orderYear = orderDate.getFullYear();
const orderQuarter = Math.floor(orderDate.getMonth() / 3) + 1;
return orderYear === targetYear && orderQuarter === targetQuarter;
}
// Handle year format (e.g., "2025")
if (dateRange.match(/^\d{4}$/)) {
return orderDate.getFullYear() === parseInt(dateRange);
}
// Handle custom date range format (e.g., "2025-01-01_2025-12-31")
if (dateRange.includes('_')) {
const [startDate, endDate] = dateRange.split('_');
const start = new Date(startDate);
const end = new Date(endDate);
return orderDate >= start && orderDate <= end;
}
// Handle legacy preset ranges
const timeDiff = now.getTime() - orderDate.getTime();
const daysDiff = timeDiff / (24 * 60 * 60 * 1000);
switch (dateRange) {
case 'week':
return daysDiff >= 0 && daysDiff <= 7;
case 'month':
return daysDiff >= 0 && daysDiff <= 30;
case 'quarter':
return daysDiff >= 0 && daysDiff <= 90;
case 'year':
return daysDiff >= 0 && daysDiff <= 365;
default:
return true;
}
});
return filteredOrders;
} }
/** /**