From e3b51a1629dddcdbc66182f8307efaa837115da8 Mon Sep 17 00:00:00 2001 From: Kavehhn174 Date: Sun, 23 Aug 2026 18:24:32 +0330 Subject: [PATCH] feat(financial-reports): add institute-wide analytics endpoint Adds GET /financial-reports/admin/analytics returning daily/weekly income trends (cash received vs. accrued session income), a multi-month received/payout/expense/profit breakdown, a per-class income-per-session leaderboard, payment-status distribution, and a due-date driven cash-flow forecast (overdue + upcoming installments) to power richer charts on the dashboard's financial reports page. --- .../financialReportController.js | 5 + .../financialReports/financialReportRoutes.js | 1 + .../financialReportService.js | 471 +++++++++++++++++- utils/reportBuckets.js | 111 +++++ 4 files changed, 587 insertions(+), 1 deletion(-) create mode 100644 utils/reportBuckets.js diff --git a/components/financialReports/financialReportController.js b/components/financialReports/financialReportController.js index 1e49dee..2d87282 100644 --- a/components/financialReports/financialReportController.js +++ b/components/financialReports/financialReportController.js @@ -19,3 +19,8 @@ exports.getRangeReport = catchAsync(async (req, res) => { const report = await financialReportService.getRangeReport(req.query); return successResponse(res, 200, 'Date-range financial report retrieved successfully', report); }); + +exports.getAnalytics = catchAsync(async (req, res) => { + const report = await financialReportService.getAnalytics(req.query); + return successResponse(res, 200, 'Financial analytics retrieved successfully', report); +}); diff --git a/components/financialReports/financialReportRoutes.js b/components/financialReports/financialReportRoutes.js index b9bb205..a6b5343 100644 --- a/components/financialReports/financialReportRoutes.js +++ b/components/financialReports/financialReportRoutes.js @@ -12,6 +12,7 @@ const router = express.Router(); router.use(authMiddleware); router.use(perm.requires(PERMISSIONS.FINANCIAL_REPORTS_READ)); +router.get('/admin/analytics', financialReportController.getAnalytics); router.get('/admin/range', financialReportController.getRangeReport); router.get('/admin/classes/:classId', financialReportController.getClassReport); router.get('/admin/sessions/:sessionId', financialReportController.getSessionReport); diff --git a/components/financialReports/financialReportService.js b/components/financialReports/financialReportService.js index dde919c..f62e889 100644 --- a/components/financialReports/financialReportService.js +++ b/components/financialReports/financialReportService.js @@ -5,8 +5,17 @@ const Class = require('../classes/classModel'); const Session = require('../sessions/sessionModel'); const Payment = require('../payments/paymentModel'); const Transaction = require('../payments/transactionModel'); +const Expense = require('../expenses/expenseModel'); const AppError = require('../../utils/AppError'); const { resolveDateRange, isWithinRange } = require('../../utils/financialRange'); +const { + startOfDay, + buildDailyBuckets, + buildWeeklyBuckets, + buildMonthlyBuckets, + buildForecastWeeklyBuckets, + findBucketIndex +} = require('../../utils/reportBuckets'); const { getPayableAmount, isPaidTransaction, @@ -335,8 +344,468 @@ const getRangeReport = async (query = {}) => { }; }; +const clampInt = (value, fallback, min, max) => { + const n = parseInt(value, 10); + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, n)); +}; + +const buildStudentName = (user) => { + if (!user || typeof user !== 'object') return '—'; + return user.name || '—'; +}; + +const emptyDaily = (buckets) => buckets.map((b) => ({ date: b.key, received: 0, sessionsHeld: 0, sessionIncome: 0 })); +const emptyWeekly = (buckets) => buckets.map((b) => ({ + weekStart: b.key, + weekEnd: b.end.toISOString().slice(0, 10), + received: 0, + outstanding: 0, + sessionsHeld: 0, + sessionIncome: 0 +})); +const emptyMonthly = (buckets) => buckets.map((b) => ({ + year: b.year, + month: b.month, + monthStart: b.start, + received: 0, + outstanding: 0, + sessionsHeld: 0, + professorPayouts: 0, + generalExpenses: 0, + netProfit: 0 +})); +const emptyForecastBuckets = (buckets) => buckets.map((b) => ({ + weekStart: b.key, + weekEnd: b.end.toISOString().slice(0, 10), + expectedAmount: 0, + count: 0 +})); + +/** + * Institute-wide financial analytics: daily & weekly income trends (cash received vs. + * accrued session income), a multi-month received/payout/expense/profit breakdown, + * per-class income leaderboard, payment-status distribution, and a forward-looking + * cash-flow forecast built from outstanding transactions' due dates. + */ +const getAnalytics = async (query = {}) => { + const now = new Date(); + const dailyDays = clampInt(query.dailyDays, 30, 7, 90); + const weeklyWeeks = clampInt(query.weeklyWeeks, 12, 4, 26); + const monthlyMonths = clampInt(query.monthlyMonths, 6, 3, 24); + const forecastWeeks = clampInt(query.forecastWeeks, 8, 4, 16); + + const dailyBuckets = buildDailyBuckets(dailyDays, now); + const weeklyBuckets = buildWeeklyBuckets(weeklyWeeks, now); + const monthlyBuckets = buildMonthlyBuckets(monthlyMonths, now); + const forecastBuckets = buildForecastWeeklyBuckets(forecastWeeks, now); + + const earliestWindowStart = [dailyBuckets[0]?.start, weeklyBuckets[0]?.start, monthlyBuckets[0]?.start] + .filter(Boolean) + .reduce((min, d) => (d.getTime() < min.getTime() ? d : min), now); + + const classes = await Class.find({ isDeleted: { $ne: true } }) + .populate({ path: 'course', select: 'title hoursPerSection' }) + .populate({ path: 'professor', select: 'name surname' }) + .lean(); + const classIds = classes.map((c) => c._id); + + if (!classIds.length) { + const generalExpensesAllTime = await sumExpensesInRange(new Date(0), now); + return { + generatedAt: now, + daily: emptyDaily(dailyBuckets), + weekly: emptyWeekly(weeklyBuckets), + monthly: emptyMonthly(monthlyBuckets), + overview: { + totalExpectedRevenue: 0, + totalReceived: 0, + totalPendingReceivables: 0, + overdueAmount: 0, + overdueCount: 0, + totalProfessorPayoutsAllTime: 0, + totalGeneralExpensesAllTime: generalExpensesAllTime, + netProfitAllTime: -generalExpensesAllTime, + totalEnrollments: 0, + totalActiveClasses: 0, + totalClasses: 0, + totalSessionsHeld: 0, + totalSessionsPlanned: 0, + avgIncomePerHeldSession: 0, + avgExpectedIncomePerPlannedSession: 0 + }, + incomeByClass: [], + paymentStatusBreakdown: [], + upcomingDue: [], + forecast: { + overdueAmount: 0, + overdueCount: 0, + next7DaysAmount: 0, + next30DaysAmount: 0, + next60DaysAmount: 0, + buckets: emptyForecastBuckets(forecastBuckets) + } + }; + } + + const [revenueByClass, sessions, payments] = await Promise.all([ + getRevenueByClass(classIds), + Session.find({ class: { $in: classIds }, isDeleted: { $ne: true } }).select('class day status').lean(), + Payment.find({ classes: { $in: classIds }, isDeleted: { $ne: true } }) + .select('classes user') + .populate('user', 'name') + .lean() + ]); + + const paymentClassByPaymentId = new Map(); + const paymentUserByPaymentId = new Map(); + const paymentIds = []; + for (const payment of payments) { + paymentIds.push(payment._id); + const matchedClassId = (payment.classes || []).map(normalizeId).find((id) => classIds.some((c) => String(c) === id)); + paymentClassByPaymentId.set(String(payment._id), matchedClassId); + paymentUserByPaymentId.set(String(payment._id), payment.user); + } + + const [transactions, expenses, generalExpensesAllTime, paymentsFull] = await Promise.all([ + paymentIds.length + ? Transaction.find({ payment: { $in: paymentIds } }).select('payment amount status date dueDate').lean() + : [], + Expense.find({ date: { $gte: startOfDay(earliestWindowStart) } }).select('date amount').lean(), + sumExpensesInRange(new Date(0), now), + Payment.find({ isDeleted: { $ne: true } }).select('status amount discount').lean() + ]); + + const sessionCountsByClass = new Map(classIds.map((id) => [String(id), { total: 0, held: 0 }])); + for (const s of sessions) { + const key = String(s.class); + const entry = sessionCountsByClass.get(key); + if (!entry) continue; + entry.total += 1; + if (s.status === 'held') entry.held += 1; + } + + // ── Per-class financial profile (used throughout every bucket + the leaderboard) ── + const classProfiles = new Map(); + for (const cls of classes) { + const key = String(cls._id); + const revenue = revenueByClass.get(key) || { expectedRevenue: 0, actualReceivedRevenue: 0 }; + const sessionCounts = sessionCountsByClass.get(key) || { total: 0, held: 0 }; + const studentsCount = (cls.students || []).length; + const plannedSessions = cls.numberOfSessions ?? sessionCounts.total; + const finalTuitionFee = cls.hasDiscount ? Math.max(0, (cls.tuitionFee || 0) - (cls.discount || 0)) : (cls.tuitionFee || 0); + const assignedTuitionPerStudent = studentsCount > 0 && revenue.expectedRevenue > 0 + ? revenue.expectedRevenue / studentsCount + : finalTuitionFee; + const perSessionIncomeEstimate = calculateStudentRevenuePerSession(assignedTuitionPerStudent, plannedSessions) * studentsCount; + + classProfiles.set(key, { + id: cls._id, + name: cls.name, + professorName: cls.professor ? `${cls.professor.name || ''} ${cls.professor.surname || ''}`.trim() || '—' : '—', + courseName: (cls.course && cls.course.title) || '—', + isActive: cls.isActive !== false, + payoutType: cls.payoutType, + payoutPercentage: cls.payoutPercentage, + payoutHourlyRate: cls.payoutHourlyRate, + extraExpensePerSession: cls.extraExpensePerSession, + sessionDurationHours: resolveSessionDurationHours(cls), + studentsCount, + plannedSessions, + heldSessions: sessionCounts.held, + expectedRevenue: revenue.expectedRevenue, + actualReceivedRevenue: revenue.actualReceivedRevenue, + perSessionIncomeEstimate + }); + } + + // ── Daily / weekly / monthly buckets ────────────────────────────────────── + const dailyReceived = new Array(dailyBuckets.length).fill(0); + const dailyHeld = new Array(dailyBuckets.length).fill(0); + const dailySessionIncome = new Array(dailyBuckets.length).fill(0); + + const weeklyReceived = new Array(weeklyBuckets.length).fill(0); + const weeklyOutstanding = new Array(weeklyBuckets.length).fill(0); + const weeklyHeld = new Array(weeklyBuckets.length).fill(0); + const weeklySessionIncome = new Array(weeklyBuckets.length).fill(0); + + const monthlyReceived = new Array(monthlyBuckets.length).fill(0); + const monthlyOutstanding = new Array(monthlyBuckets.length).fill(0); + const monthlyHeld = new Array(monthlyBuckets.length).fill(0); + const monthlyExpenses = new Array(monthlyBuckets.length).fill(0); + const monthlyClassReceived = monthlyBuckets.map(() => new Map()); + const monthlyClassHeld = monthlyBuckets.map(() => new Map()); + + for (const s of sessions) { + if (s.status !== 'held' || !s.day) continue; + const classKey = String(s.class); + const profile = classProfiles.get(classKey); + const perSessionIncome = profile ? profile.perSessionIncomeEstimate : 0; + + const di = findBucketIndex(dailyBuckets, s.day); + if (di >= 0) { + dailyHeld[di] += 1; + dailySessionIncome[di] += perSessionIncome; + } + + const wi = findBucketIndex(weeklyBuckets, s.day); + if (wi >= 0) { + weeklyHeld[wi] += 1; + weeklySessionIncome[wi] += perSessionIncome; + } + + const mi = findBucketIndex(monthlyBuckets, s.day); + if (mi >= 0) { + monthlyHeld[mi] += 1; + const map = monthlyClassHeld[mi]; + map.set(classKey, (map.get(classKey) || 0) + 1); + } + } + + for (const trx of transactions) { + const classKey = paymentClassByPaymentId.get(String(trx.payment)); + const paid = isPaidTransaction(trx); + const active = isActiveTransaction(trx); + const amount = Number(trx.amount) || 0; + + if (paid && trx.date) { + const di = findBucketIndex(dailyBuckets, trx.date); + if (di >= 0) dailyReceived[di] += amount; + + const wi = findBucketIndex(weeklyBuckets, trx.date); + if (wi >= 0) weeklyReceived[wi] += amount; + + const mi = findBucketIndex(monthlyBuckets, trx.date); + if (mi >= 0) { + monthlyReceived[mi] += amount; + if (classKey) { + const map = monthlyClassReceived[mi]; + map.set(classKey, (map.get(classKey) || 0) + amount); + } + } + } else if (active && !paid && trx.dueDate) { + const wi = findBucketIndex(weeklyBuckets, trx.dueDate); + if (wi >= 0) weeklyOutstanding[wi] += amount; + + const mi = findBucketIndex(monthlyBuckets, trx.dueDate); + if (mi >= 0) monthlyOutstanding[mi] += amount; + } + } + + for (const exp of expenses) { + const mi = findBucketIndex(monthlyBuckets, exp.date); + if (mi >= 0) monthlyExpenses[mi] += Number(exp.amount) || 0; + } + + const monthlyProfessorPayouts = monthlyBuckets.map((_, mi) => { + let total = 0; + for (const [classKey, profile] of classProfiles.entries()) { + const received = monthlyClassReceived[mi].get(classKey) || 0; + const held = monthlyClassHeld[mi].get(classKey) || 0; + if (!received && !held) continue; + const payout = calculateProfessorPayout({ + payoutType: profile.payoutType, + payoutPercentage: profile.payoutPercentage, + payoutHourlyRate: profile.payoutHourlyRate, + revenue: received, + sessionDurationHours: profile.sessionDurationHours, + sessionsCount: held, + extraExpensePerSession: profile.extraExpensePerSession + }); + total += payout.totalPayout; + } + return total; + }); + + const daily = dailyBuckets.map((b, i) => ({ + date: b.key, + received: dailyReceived[i], + sessionsHeld: dailyHeld[i], + sessionIncome: dailySessionIncome[i] + })); + + const weekly = weeklyBuckets.map((b, i) => ({ + weekStart: b.key, + weekEnd: b.end.toISOString().slice(0, 10), + received: weeklyReceived[i], + outstanding: weeklyOutstanding[i], + sessionsHeld: weeklyHeld[i], + sessionIncome: weeklySessionIncome[i] + })); + + const monthly = monthlyBuckets.map((b, i) => ({ + year: b.year, + month: b.month, + monthStart: b.start, + received: monthlyReceived[i], + outstanding: monthlyOutstanding[i], + sessionsHeld: monthlyHeld[i], + professorPayouts: monthlyProfessorPayouts[i], + generalExpenses: monthlyExpenses[i], + netProfit: monthlyReceived[i] - monthlyProfessorPayouts[i] - monthlyExpenses[i] + })); + + // ── All-time overview + per-class leaderboard ───────────────────────────── + let totalExpectedRevenue = 0; + let totalReceived = 0; + let totalProfessorPayoutsAllTime = 0; + let totalSessionsPlanned = 0; + let totalSessionsHeldAllTime = 0; + let totalEnrollments = 0; + let totalActiveClasses = 0; + + const incomeByClass = []; + for (const profile of classProfiles.values()) { + totalExpectedRevenue += profile.expectedRevenue; + totalReceived += profile.actualReceivedRevenue; + totalSessionsPlanned += profile.plannedSessions || 0; + totalSessionsHeldAllTime += profile.heldSessions; + totalEnrollments += profile.studentsCount; + if (profile.isActive) totalActiveClasses += 1; + + const payout = calculateProfessorPayout({ + payoutType: profile.payoutType, + payoutPercentage: profile.payoutPercentage, + payoutHourlyRate: profile.payoutHourlyRate, + revenue: profile.actualReceivedRevenue, + sessionDurationHours: profile.sessionDurationHours, + sessionsCount: profile.heldSessions, + extraExpensePerSession: profile.extraExpensePerSession + }); + totalProfessorPayoutsAllTime += payout.totalPayout; + + incomeByClass.push({ + classId: profile.id, + className: profile.name, + professorName: profile.professorName, + courseName: profile.courseName, + studentsCount: profile.studentsCount, + expectedRevenue: profile.expectedRevenue, + actualReceivedRevenue: profile.actualReceivedRevenue, + pendingReceivables: calculatePendingReceivables(profile.expectedRevenue, profile.actualReceivedRevenue), + sessionsHeld: profile.heldSessions, + sessionsPlanned: profile.plannedSessions, + incomePerSessionActual: profile.heldSessions > 0 ? profile.actualReceivedRevenue / profile.heldSessions : 0, + incomePerSessionExpected: profile.plannedSessions > 0 ? profile.expectedRevenue / profile.plannedSessions : 0 + }); + } + incomeByClass.sort((a, b) => b.actualReceivedRevenue - a.actualReceivedRevenue); + + const totalPendingReceivables = calculatePendingReceivables(totalExpectedRevenue, totalReceived); + const netProfitAllTime = totalReceived - totalProfessorPayoutsAllTime - generalExpensesAllTime; + + let overdueAmount = 0; + let overdueCount = 0; + for (const trx of transactions) { + if (isActiveTransaction(trx) && !isPaidTransaction(trx) && trx.dueDate && new Date(trx.dueDate).getTime() < now.getTime()) { + overdueAmount += Number(trx.amount) || 0; + overdueCount += 1; + } + } + + const statusMap = new Map(); + for (const p of paymentsFull) { + const status = p.status || 'pending'; + const entry = statusMap.get(status) || { status, count: 0, amount: 0 }; + entry.count += 1; + entry.amount += getPayableAmount(p); + statusMap.set(status, entry); + } + const paymentStatusBreakdown = Array.from(statusMap.values()); + + // ── Forecast: upcoming due dates → expected cash inflow ─────────────────── + const pendingTransactions = transactions.filter((t) => isActiveTransaction(t) && !isPaidTransaction(t) && t.dueDate); + const forecastBucketTotals = forecastBuckets.map(() => ({ amount: 0, count: 0 })); + const in7 = now.getTime() + 7 * 86400000; + const in30 = now.getTime() + 30 * 86400000; + const in60 = now.getTime() + 60 * 86400000; + let next7DaysAmount = 0; + let next30DaysAmount = 0; + let next60DaysAmount = 0; + + for (const trx of pendingTransactions) { + const due = new Date(trx.dueDate); + const dueTime = due.getTime(); + const amount = Number(trx.amount) || 0; + + if (dueTime >= now.getTime()) { + if (dueTime <= in7) next7DaysAmount += amount; + if (dueTime <= in30) next30DaysAmount += amount; + if (dueTime <= in60) next60DaysAmount += amount; + } + + const fi = findBucketIndex(forecastBuckets, due); + if (fi >= 0) { + forecastBucketTotals[fi].amount += amount; + forecastBucketTotals[fi].count += 1; + } + } + + const upcomingDue = pendingTransactions + .slice() + .sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()) + .slice(0, 50) + .map((trx) => { + const classKey = paymentClassByPaymentId.get(String(trx.payment)); + const profile = classProfiles.get(classKey); + const studentUser = paymentUserByPaymentId.get(String(trx.payment)); + const due = new Date(trx.dueDate); + const daysUntilDue = Math.round((due.getTime() - now.getTime()) / 86400000); + return { + transactionId: trx._id, + studentName: buildStudentName(studentUser), + className: profile ? profile.name : '—', + amount: Number(trx.amount) || 0, + dueDate: trx.dueDate, + daysUntilDue, + isOverdue: daysUntilDue < 0 + }; + }); + + return { + generatedAt: now, + daily, + weekly, + monthly, + overview: { + totalExpectedRevenue, + totalReceived, + totalPendingReceivables, + overdueAmount, + overdueCount, + totalProfessorPayoutsAllTime, + totalGeneralExpensesAllTime: generalExpensesAllTime, + netProfitAllTime, + totalEnrollments, + totalActiveClasses, + totalClasses: classes.length, + totalSessionsHeld: totalSessionsHeldAllTime, + totalSessionsPlanned, + avgIncomePerHeldSession: totalSessionsHeldAllTime > 0 ? totalReceived / totalSessionsHeldAllTime : 0, + avgExpectedIncomePerPlannedSession: totalSessionsPlanned > 0 ? totalExpectedRevenue / totalSessionsPlanned : 0 + }, + incomeByClass, + paymentStatusBreakdown, + upcomingDue, + forecast: { + overdueAmount, + overdueCount, + next7DaysAmount, + next30DaysAmount, + next60DaysAmount, + buckets: forecastBuckets.map((b, i) => ({ + weekStart: b.key, + weekEnd: b.end.toISOString().slice(0, 10), + expectedAmount: forecastBucketTotals[i].amount, + count: forecastBucketTotals[i].count + })) + } + }; +}; + module.exports = { getClassReport, getSessionReport, - getRangeReport + getRangeReport, + getAnalytics }; diff --git a/utils/reportBuckets.js b/utils/reportBuckets.js new file mode 100644 index 0000000..ee7b4bd --- /dev/null +++ b/utils/reportBuckets.js @@ -0,0 +1,111 @@ +'use strict'; + +/** + * Time-bucketing helpers for financial analytics (daily / weekly / monthly / + * forward-looking forecast windows). Weeks follow the Iranian convention of + * starting on Saturday, ending on Friday. + */ + +const startOfDay = (date) => { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; +}; + +const endOfDay = (date) => { + const d = new Date(date); + d.setHours(23, 59, 59, 999); + return d; +}; + +const addDays = (date, days) => { + const d = new Date(date); + d.setDate(d.getDate() + days); + return d; +}; + +const addMonths = (date, months) => { + const d = new Date(date); + d.setMonth(d.getMonth() + months); + return d; +}; + +/** JS getDay(): Sun=0..Sat=6. Iranian week starts Saturday, so Sat -> offset 0, Sun -> 1, ... Fri -> 6. */ +const getWeekStart = (date) => { + const d = startOfDay(date); + const offset = (d.getDay() + 1) % 7; + return addDays(d, -offset); +}; + +/** Builds `days` daily buckets ending on (and including) `endDate`, oldest first. */ +const buildDailyBuckets = (days, endDate = new Date()) => { + const end = startOfDay(endDate); + const buckets = []; + for (let i = days - 1; i >= 0; i--) { + const dayStart = addDays(end, -i); + buckets.push({ start: dayStart, end: endOfDay(dayStart), key: dayStart.toISOString().slice(0, 10) }); + } + return buckets; +}; + +/** Builds `weeks` weekly buckets (Sat-Fri) ending on the week containing `endDate`, oldest first. */ +const buildWeeklyBuckets = (weeks, endDate = new Date()) => { + const currentWeekStart = getWeekStart(endDate); + const buckets = []; + for (let i = weeks - 1; i >= 0; i--) { + const weekStart = addDays(currentWeekStart, -7 * i); + buckets.push({ start: weekStart, end: endOfDay(addDays(weekStart, 6)), key: weekStart.toISOString().slice(0, 10) }); + } + return buckets; +}; + +/** Builds `weeks` forward-looking weekly buckets (Sat-Fri) starting from the week containing `startDate`. */ +const buildForecastWeeklyBuckets = (weeks, startDate = new Date()) => { + const currentWeekStart = getWeekStart(startDate); + const buckets = []; + for (let i = 0; i < weeks; i++) { + const weekStart = addDays(currentWeekStart, 7 * i); + buckets.push({ start: weekStart, end: endOfDay(addDays(weekStart, 6)), key: weekStart.toISOString().slice(0, 10) }); + } + return buckets; +}; + +/** Builds `months` monthly (Gregorian calendar month) buckets ending on the month containing `endDate`, oldest first. */ +const buildMonthlyBuckets = (months, endDate = new Date()) => { + const base = new Date(endDate.getFullYear(), endDate.getMonth(), 1, 0, 0, 0, 0); + const buckets = []; + for (let i = months - 1; i >= 0; i--) { + const monthStart = addMonths(base, -i); + const monthEnd = endOfDay(addDays(addMonths(monthStart, 1), -1)); + buckets.push({ + start: monthStart, + end: monthEnd, + year: monthStart.getFullYear(), + month: monthStart.getMonth() + 1, + key: `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, '0')}` + }); + } + return buckets; +}; + +/** Linear search is fine — bucket arrays used here are always small (<=~90 entries). */ +const findBucketIndex = (buckets, date) => { + if (!date) return -1; + const d = date instanceof Date ? date : new Date(date); + if (Number.isNaN(d.getTime())) return -1; + const t = d.getTime(); + return buckets.findIndex((b) => t >= b.start.getTime() && t <= b.end.getTime()); +}; + +module.exports = { + startOfDay, + endOfDay, + addDays, + addMonths, + getWeekStart, + buildDailyBuckets, + buildWeeklyBuckets, + buildForecastWeeklyBuckets, + buildMonthlyBuckets, + findBucketIndex +};