From f2bee2b1caa0a71bb9ca0eb1b7eb823ae113411b Mon Sep 17 00:00:00 2001 From: David Amunga Date: Tue, 18 Aug 2026 11:49:46 +0300 Subject: [PATCH] feat: add optional Day-of-Week Activity Excel sheet --- .changeset/add-day-of-week-sheet.md | 5 + README.md | 2 +- src-tauri/Cargo.lock | 2 +- src/components/export-options.tsx | 6 + .../exports/dayOfWeekActivitySheet.ts | 401 ++++++++++++++++++ src/services/exports/index.ts | 1 + src/services/xlsxService.ts | 5 + src/shots/CaptureApp.tsx | 1 + src/types/index.ts | 1 + 9 files changed, 422 insertions(+), 2 deletions(-) create mode 100644 .changeset/add-day-of-week-sheet.md create mode 100644 src/services/exports/dayOfWeekActivitySheet.ts diff --git a/.changeset/add-day-of-week-sheet.md b/.changeset/add-day-of-week-sheet.md new file mode 100644 index 0000000..a7c0931 --- /dev/null +++ b/.changeset/add-day-of-week-sheet.md @@ -0,0 +1,5 @@ +--- +"mpesa2csv": minor +--- + +Add optional Day-of-Week Activity Excel analysis sheet. diff --git a/README.md b/README.md index 0b97510..030a0b8 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ M-PESA Statement PDFs arrive password-locked. mpesa2csv unlocks them on your mac ### Excel analysis sheets -Optionally include sheets for money in/out, charges, cash-flow summary, monthly/weekly breakdown, daily balance, amount distribution, top contacts, recurring payments, and time-of-day patterns. +Optionally include sheets for money in/out, charges, reversals, cash-flow summary, monthly/weekly breakdown, daily balance, amount distribution, top contacts, recurring payments, time-of-day patterns, and day-of-week activity. ### Privacy diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 063a464..eb655b6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2141,7 +2141,7 @@ dependencies = [ [[package]] name = "mpesa2csv" -version = "1.0.2" +version = "1.1.0" dependencies = [ "serde", "serde_json", diff --git a/src/components/export-options.tsx b/src/components/export-options.tsx index dd04eda..7de8f79 100644 --- a/src/components/export-options.tsx +++ b/src/components/export-options.tsx @@ -121,6 +121,12 @@ const SHEET_GROUPS = [ name: "Time of Day", description: "Transaction activity by hour — night, morning, afternoon, evening", }, + { + key: "includeDayOfWeekSheet" as keyof ExportOptionsType, + name: "Day of Week", + description: + "Transaction activity by weekday — peak days and weekday vs weekend", + }, ], }, ]; diff --git a/src/services/exports/dayOfWeekActivitySheet.ts b/src/services/exports/dayOfWeekActivitySheet.ts new file mode 100644 index 0000000..8352081 --- /dev/null +++ b/src/services/exports/dayOfWeekActivitySheet.ts @@ -0,0 +1,401 @@ +import { MPesaStatement } from "../../types"; +import * as ExcelJS from "exceljs"; + +interface DayData { + dayIndex: number; + dayName: string; + transactionCount: number; + moneyInCount: number; + moneyInTotal: number; + moneyOutCount: number; + moneyOutTotal: number; + netFlow: number; + percentOfTotal: number; +} + +interface WeekPartData { + part: string; + days: string; + transactionCount: number; + percentOfTotal: number; + moneyInTotal: number; + moneyOutTotal: number; + netFlow: number; +} + +/** Monday-first order for display; Date#getDay() uses Sunday = 0. */ +const DAY_ORDER = [ + { jsDay: 1, name: "Monday" }, + { jsDay: 2, name: "Tuesday" }, + { jsDay: 3, name: "Wednesday" }, + { jsDay: 4, name: "Thursday" }, + { jsDay: 5, name: "Friday" }, + { jsDay: 6, name: "Saturday" }, + { jsDay: 0, name: "Sunday" }, +]; + +export function addDayOfWeekActivitySheet( + workbook: ExcelJS.Workbook, + statement: MPesaStatement +): void { + if (statement.transactions.length === 0) return; + + const dayData = buildDayData(statement.transactions); + const weekPartData = buildWeekPartData(dayData, statement.transactions.length); + + const worksheet = workbook.addWorksheet("Day-of-Week Activity"); + + let currentRow = 1; + + // Title + worksheet.mergeCells(`A${currentRow}:I${currentRow}`); + worksheet.getCell(`A${currentRow}`).value = "DAY-OF-WEEK ACTIVITY"; + worksheet.getCell(`A${currentRow}`).font = { + bold: true, + size: 16, + color: { argb: "FFFFFFFF" }, + }; + worksheet.getCell(`A${currentRow}`).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FF2E4057" }, + }; + worksheet.getCell(`A${currentRow}`).alignment = { horizontal: "center" }; + currentRow += 2; + + // Section A: Daily Breakdown + worksheet.mergeCells(`A${currentRow}:I${currentRow}`); + worksheet.getCell(`A${currentRow}`).value = "DAILY BREAKDOWN"; + worksheet.getCell(`A${currentRow}`).font = { bold: true, size: 14 }; + worksheet.getCell(`A${currentRow}`).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE7E6E6" }, + }; + worksheet.getCell(`A${currentRow}`).alignment = { horizontal: "center" }; + currentRow++; + + const dayHeaders = [ + "Day", + "Transactions", + "% of Total", + "Money In Count", + "Money In (KSh)", + "Money Out Count", + "Money Out (KSh)", + "Net Flow (KSh)", + ]; + + dayHeaders.forEach((header, index) => { + const cell = worksheet.getCell(currentRow, index + 1); + cell.value = header; + cell.font = { bold: true }; + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFD9E2F3" }, + }; + cell.alignment = { horizontal: "center" }; + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + }); + currentRow++; + + const peakDay = dayData.reduce( + (peak, d) => (d.transactionCount > peak.transactionCount ? d : peak), + dayData[0] + ); + + dayData.forEach((day) => { + const isPeak = + day.dayIndex === peakDay.dayIndex && peakDay.transactionCount > 0; + + const rowValues: (string | number)[] = [ + day.dayName, + day.transactionCount, + `${day.percentOfTotal.toFixed(1)}%`, + day.moneyInCount, + day.moneyInTotal, + day.moneyOutCount, + day.moneyOutTotal, + day.netFlow, + ]; + + rowValues.forEach((value, colIndex) => { + const cell = worksheet.getCell(currentRow, colIndex + 1); + cell.value = value; + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + + if (colIndex === 4 || colIndex === 6 || colIndex === 7) { + cell.numFmt = "#,##0.00"; + } + + if (isPeak) { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFF2CC" }, + }; + if (colIndex === 0) { + cell.font = { bold: true, color: { argb: "FF7D6608" } }; + } + } + + if (colIndex === 7) { + cell.font = { + ...(isPeak ? { bold: true } : {}), + color: { argb: day.netFlow >= 0 ? "FF008000" : "FFCC0000" }, + }; + } + }); + + currentRow++; + }); + + // Section B: Weekday vs Weekend + currentRow += 2; + worksheet.mergeCells(`A${currentRow}:G${currentRow}`); + worksheet.getCell(`A${currentRow}`).value = "WEEKDAY VS WEEKEND"; + worksheet.getCell(`A${currentRow}`).font = { bold: true, size: 14 }; + worksheet.getCell(`A${currentRow}`).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE7E6E6" }, + }; + worksheet.getCell(`A${currentRow}`).alignment = { horizontal: "center" }; + currentRow++; + + const partHeaders = [ + "Part of Week", + "Days", + "Transactions", + "% of Total", + "Money In (KSh)", + "Money Out (KSh)", + "Net Flow (KSh)", + ]; + + partHeaders.forEach((header, index) => { + const cell = worksheet.getCell(currentRow, index + 1); + cell.value = header; + cell.font = { bold: true }; + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFD9E2F3" }, + }; + cell.alignment = { horizontal: "center" }; + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + }); + currentRow++; + + const mostActivePart = weekPartData.reduce( + (best, p) => (p.transactionCount > best.transactionCount ? p : best), + weekPartData[0] + ); + + weekPartData.forEach((part) => { + const isActive = + part.part === mostActivePart.part && mostActivePart.transactionCount > 0; + + const rowValues: (string | number)[] = [ + part.part, + part.days, + part.transactionCount, + `${part.percentOfTotal.toFixed(1)}%`, + part.moneyInTotal, + part.moneyOutTotal, + part.netFlow, + ]; + + rowValues.forEach((value, colIndex) => { + const cell = worksheet.getCell(currentRow, colIndex + 1); + cell.value = value; + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + + if (colIndex >= 4 && colIndex <= 6) { + cell.numFmt = "#,##0.00"; + } + + if (isActive) { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFF2CC" }, + }; + if (colIndex === 0) { + cell.font = { bold: true, color: { argb: "FF7D6608" } }; + } + } + + if (colIndex === 6) { + cell.font = { + color: { argb: part.netFlow >= 0 ? "FF008000" : "FFCC0000" }, + }; + } + }); + + currentRow++; + }); + + // Section C: Key Insights + currentRow += 2; + worksheet.mergeCells(`A${currentRow}:B${currentRow}`); + worksheet.getCell(`A${currentRow}`).value = "KEY INSIGHTS"; + worksheet.getCell(`A${currentRow}`).font = { bold: true, size: 14 }; + worksheet.getCell(`A${currentRow}`).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE7E6E6" }, + }; + worksheet.getCell(`A${currentRow}`).alignment = { horizontal: "center" }; + currentRow++; + + const peakMoneyInDay = dayData.reduce( + (peak, d) => (d.moneyInTotal > peak.moneyInTotal ? d : peak), + dayData[0] + ); + const peakMoneyOutDay = dayData.reduce( + (peak, d) => (d.moneyOutTotal > peak.moneyOutTotal ? d : peak), + dayData[0] + ); + const quietestDay = dayData.reduce( + (quiet, d) => + d.transactionCount < quiet.transactionCount ? d : quiet, + dayData[0] + ); + + const insights: [string, string | number][] = [ + ["Peak Day (most transactions):", peakDay.dayName], + ["Peak Money-In Day:", peakMoneyInDay.dayName], + ["Peak Money-Out Day:", peakMoneyOutDay.dayName], + ["Most Active Part of Week:", mostActivePart.part], + ["Quietest Day:", quietestDay.dayName], + ]; + + insights.forEach(([label, value]) => { + worksheet.getCell(`A${currentRow}`).value = label; + worksheet.getCell(`A${currentRow}`).font = { bold: true }; + worksheet.getCell(`B${currentRow}`).value = value; + currentRow++; + }); + + worksheet.columns = [ + { width: 18 }, + { width: 28 }, + { width: 14 }, + { width: 14 }, + { width: 18 }, + { width: 18 }, + { width: 18 }, + { width: 18 }, + ]; +} + +function buildDayData(transactions: MPesaStatement["transactions"]): DayData[] { + const byJsDay = new Map>(); + + DAY_ORDER.forEach(({ jsDay, name }) => { + byJsDay.set(jsDay, { + dayIndex: jsDay, + dayName: name, + transactionCount: 0, + moneyInCount: 0, + moneyInTotal: 0, + moneyOutCount: 0, + moneyOutTotal: 0, + netFlow: 0, + }); + }); + + transactions.forEach((t) => { + const jsDay = new Date(t.completionTime).getDay(); + const bucket = byJsDay.get(jsDay); + if (!bucket) return; + + bucket.transactionCount++; + if (t.paidIn !== null && t.paidIn > 0) { + bucket.moneyInCount++; + bucket.moneyInTotal += t.paidIn; + } + if (t.withdrawn !== null && t.withdrawn > 0) { + bucket.moneyOutCount++; + bucket.moneyOutTotal += t.withdrawn; + } + bucket.netFlow = bucket.moneyInTotal - bucket.moneyOutTotal; + }); + + const total = transactions.length; + + return DAY_ORDER.map(({ jsDay }) => { + const day = byJsDay.get(jsDay)!; + return { + ...day, + percentOfTotal: total > 0 ? (day.transactionCount / total) * 100 : 0, + }; + }); +} + +function buildWeekPartData( + dayData: DayData[], + totalTransactions: number +): WeekPartData[] { + const parts: { + part: string; + days: string; + dayIndexes: number[]; + }[] = [ + { + part: "Weekdays", + days: "Mon – Fri", + dayIndexes: [1, 2, 3, 4, 5], + }, + { + part: "Weekend", + days: "Sat – Sun", + dayIndexes: [6, 0], + }, + ]; + + return parts.map(({ part, days, dayIndexes }) => { + const matching = dayData.filter((d) => dayIndexes.includes(d.dayIndex)); + const transactionCount = matching.reduce( + (sum, d) => sum + d.transactionCount, + 0 + ); + const moneyInTotal = matching.reduce((sum, d) => sum + d.moneyInTotal, 0); + const moneyOutTotal = matching.reduce((sum, d) => sum + d.moneyOutTotal, 0); + const netFlow = moneyInTotal - moneyOutTotal; + const percentOfTotal = + totalTransactions > 0 ? (transactionCount / totalTransactions) * 100 : 0; + + return { + part, + days, + transactionCount, + percentOfTotal, + moneyInTotal, + moneyOutTotal, + netFlow, + }; + }); +} diff --git a/src/services/exports/index.ts b/src/services/exports/index.ts index d983bac..4ac78d4 100644 --- a/src/services/exports/index.ts +++ b/src/services/exports/index.ts @@ -8,5 +8,6 @@ export { addMoneyInSheet } from "./moneyInSheet"; export { addMoneyOutSheet } from "./moneyOutSheet"; export { addRecurringTransactionsSheet } from "./recurringTransactionsSheet"; export { addTimeOfDayActivitySheet } from "./timeOfDayActivitySheet"; +export { addDayOfWeekActivitySheet } from "./dayOfWeekActivitySheet"; export { addReversalsSheet } from "./reversalsSheet"; export { addPayBillsTillsSheet } from "./payBillsTillsSheet"; diff --git a/src/services/xlsxService.ts b/src/services/xlsxService.ts index 1210441..f573056 100644 --- a/src/services/xlsxService.ts +++ b/src/services/xlsxService.ts @@ -11,6 +11,7 @@ import { addMoneyOutSheet, addRecurringTransactionsSheet, addTimeOfDayActivitySheet, + addDayOfWeekActivitySheet, addReversalsSheet, addPayBillsTillsSheet, } from "./exports"; @@ -169,6 +170,10 @@ export class XlsxService { addTimeOfDayActivitySheet(workbook, filteredStatement); } + if (options?.includeDayOfWeekSheet) { + addDayOfWeekActivitySheet(workbook, filteredStatement); + } + const buffer = await workbook.xlsx.writeBuffer(); return buffer as ArrayBuffer; } diff --git a/src/shots/CaptureApp.tsx b/src/shots/CaptureApp.tsx index cc33876..76c2092 100644 --- a/src/shots/CaptureApp.tsx +++ b/src/shots/CaptureApp.tsx @@ -94,6 +94,7 @@ const ALL_SHEETS: ExportOptionsType = { includeRecurringTransactionsSheet: true, includeAmountDistributionSheet: true, includeTimeOfDaySheet: true, + includeDayOfWeekSheet: true, includeReversalsSheet: true, includePayBillsTillsSheet: true, filterOutCharges: true, diff --git a/src/types/index.ts b/src/types/index.ts index d3b84e5..c3ac096 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -57,6 +57,7 @@ export interface ExportOptions { includeMoneyOutSheet?: boolean; includeRecurringTransactionsSheet?: boolean; includeTimeOfDaySheet?: boolean; + includeDayOfWeekSheet?: boolean; includeReversalsSheet?: boolean; includePayBillsTillsSheet?: boolean; // Filter options