From 6c45724c32db6fb9d2b54f2af3468af7b8eb3690 Mon Sep 17 00:00:00 2001 From: David Amunga Date: Wed, 15 Jul 2026 11:42:17 +0300 Subject: [PATCH 1/7] ux: simplify success state and add animated transaction count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace busy 7-section success screen with focused hero layout: stats number → export button → two collapsibles → text links - Animate transaction count with NumberFlow on arrival - Move all export options behind a "Customize export" collapsible (closed by default — one-click export for casual users) - Smooth grid-template-rows animation on both collapsibles - Demote "Start Over" and "Share Feedback" to quiet text links - Post-save: compact chip with path + Open button replaces footer clutter - Shorten format display names (Excel, OFX, QFX, QIF) - Fix (4348) → (4,348) count formatting in collapsible header - Quieter UpdateChecker in footer (icon-only + tooltip) - Anchor success state to top of window instead of dead-center float --- .changeset/ux-success-state-simplification.md | 5 + package.json | 1 + pnpm-lock.yaml | 28 ++ src-tauri/Cargo.lock | 2 +- src/App.tsx | 302 ++++++++++-------- src/components/export-options.tsx | 182 ++++++----- src/components/update-checker.tsx | 25 ++ src/services/exportService.ts | 8 +- 8 files changed, 333 insertions(+), 220 deletions(-) create mode 100644 .changeset/ux-success-state-simplification.md diff --git a/.changeset/ux-success-state-simplification.md b/.changeset/ux-success-state-simplification.md new file mode 100644 index 0000000..065dcc8 --- /dev/null +++ b/.changeset/ux-success-state-simplification.md @@ -0,0 +1,5 @@ +--- +"mpesa2csv": patch +--- + +Simplify success state UX — animated transaction count, progressive disclosure for export options, smoother collapsibles diff --git a/package.json b/package.json index 25566d8..d005bd6 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@base-ui/react": "^1.2.0", + "@number-flow/react": "^0.6.1", "@tailwindcss/vite": "^4.1.18", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2095b54..a794a58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@base-ui/react': specifier: ^1.2.0 version: 1.2.0(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@number-flow/react': + specifier: ^0.6.1 + version: 0.6.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.6(@types/node@24.5.2)(jiti@2.6.1)(lightningcss@1.30.2)) @@ -528,6 +531,12 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@number-flow/react@0.6.1': + resolution: {integrity: sha512-JsvB8FrmtRmn251jbJnFD7Tr6Sv/eQMLUAlbshGo4CfAPk0qgNYTio9HuFoETqtuflcJHQezeGsSTPbUn1oplw==} + peerDependencies: + react: ^18 || ^19 + react-dom: ^18 || ^19 + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1074,6 +1083,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -1427,6 +1439,9 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + number-flow@0.6.1: + resolution: {integrity: sha512-5NUCwZA6J/kZeEzqApOyq7KOf1Wu/oxjpp/95HB0NBNBFlpK/Pp7i4jH8h0LIXyRDKSTewDAUaYHL7IBrk19Ew==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2250,6 +2265,13 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@number-flow/react@0.6.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + esm-env: 1.2.2 + number-flow: 0.6.1 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.62.2': @@ -2743,6 +2765,8 @@ snapshots: escalade@3.2.0: {} + esm-env@1.2.2: {} + esprima@4.0.1: {} exceljs@4.4.0: @@ -3044,6 +3068,10 @@ snapshots: normalize-path@3.0.0: {} + number-flow@0.6.1: + dependencies: + esm-env: 1.2.2 + once@1.4.0: dependencies: wrappy: 1.0.2 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index acbb44e..ed1ff26 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2141,7 +2141,7 @@ dependencies = [ [[package]] name = "mpesa2csv" -version = "0.13.0" +version = "0.14.0" dependencies = [ "serde", "serde_json", diff --git a/src/App.tsx b/src/App.tsx index d074078..fec0429 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,9 +7,10 @@ import { ExternalLink, MessageSquare, ChevronDown, - ChevronUp, Table2, X, + CheckCircle2, + Settings2, } from "lucide-react"; import { openUrl } from "@tauri-apps/plugin-opener"; @@ -29,6 +30,8 @@ import { ThemeToggle } from "./components/theme-toggle"; import { Button } from "./components/ui/button"; import { formatDateForFilename } from "./utils/helpers"; import { TIMEOUTS, URLS } from "./constants"; +import { cn } from "@/lib/utils"; +import NumberFlow from "@number-flow/react"; function App() { const [files, setFiles] = useState([]); @@ -40,6 +43,7 @@ function App() { const cancelRequested = useRef(false); // Controls the transaction-preview panel. const [previewExpanded, setPreviewExpanded] = useState(false); + const [optionsExpanded, setOptionsExpanded] = useState(false); const [exportFormat, setExportFormat] = useState( ExportFormat.XLSX ); @@ -115,6 +119,7 @@ function App() { setStatements([]); setCurrentFileIndex(0); setPreviewExpanded(false); + setOptionsExpanded(false); try { const result = await processFiles(selectedFiles); @@ -341,6 +346,7 @@ function App() { setSavedFilePath(""); setExportFileName(""); setPreviewExpanded(false); + setOptionsExpanded(false); }; const handleCancel = async () => { @@ -487,7 +493,12 @@ function App() {
-
+
{status === FileStatus.IDLE || status === FileStatus.LOADING || @@ -563,27 +574,77 @@ function App() {
) : status === FileStatus.SUCCESS && statements.length > 0 ? ( -
-
-

- ✅ Your Data is Ready! -

-

- Processed {statements[0].transactions.length} transactions - from {files.length} statement{files.length > 1 ? "s" : ""} • - Choose your export options below -

-

- Total Charges: KES{" "} - {(statements[0].totalCharges).toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} +

+ + {/* ── Stats hero ──────────────────────────────────── */} +
+ +

+ transaction{statements[0].transactions.length !== 1 ? "s" : ""} extracted + {files.length > 1 ? ` from ${files.length} statements` : ""}

+ {statements[0].totalCharges > 0 && ( +

+ KES {statements[0].totalCharges.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} in charges +

+ )}
- {/* ── Transaction Preview ────────────────────────── */} -
+ {/* ── Primary export action ───────────────────────── */} + + + {/* ── Post-save confirmation chip ─────────────────── */} + {downloadSuccess && savedFilePath && ( +
+ + + {savedFilePath} + + +
+ )} + + {/* ── Error ───────────────────────────────────────── */} + {error && ( +
+

{error}

+
+ )} + + {/* ── Transaction Preview ─────────────────────────── */} +
- {previewExpanded && ( -
- - - - - - - - - - - - {statements[0].transactions.slice(0, 10).map((tx, i) => ( - - - - - - +
+
+
+
DateDetailsPaid InWithdrawnBalance
{tx.completionTime}{tx.details} - {tx.paidIn ? tx.paidIn.toLocaleString() : ""} - - {tx.withdrawn ? tx.withdrawn.toLocaleString() : ""} - {tx.balance.toLocaleString()}
+ + + + + + + - ))} - -
DateDetailsPaid InWithdrawnBalance
- {statements[0].transactions.length > 10 && ( -

- Showing 10 of {statements[0].transactions.length} — export to see all -

- )} + + + {statements[0].transactions.slice(0, 10).map((tx, i) => ( + + {tx.completionTime} + {tx.details} + + {tx.paidIn ? tx.paidIn.toLocaleString() : ""} + + + {tx.withdrawn ? tx.withdrawn.toLocaleString() : ""} + + {tx.balance.toLocaleString()} + + ))} + + + {statements[0].transactions.length > 10 && ( +

+ Showing 10 of {statements[0].transactions.length} — export to see all +

+ )} +
- )} +
- {/* ── Export Options ──────────────────────────────── */} -
- + {/* ── Customize export (collapsible, closed by default) */} +
+ + +
+
+
+ +
+
+
- {/* ── Action Buttons ──────────────────────────────── */} -
- {/* Export is always visible so users can re-export after changing format/options */} - - - {/* Open File appears only after a successful save */} - {downloadSuccess && savedFilePath && ( - - )} - - + + + Start over +
-
-

- File: {exportFileName} -

- {downloadSuccess && savedFilePath && ( -

- Saved to: {savedFilePath} -

- )} - - {/* Feedback Link */} -
- -
-
) : null}
@@ -736,7 +778,7 @@ function App() {

{appVersion && v{appVersion}} - +
diff --git a/src/components/export-options.tsx b/src/components/export-options.tsx index fde446c..3192868 100644 --- a/src/components/export-options.tsx +++ b/src/components/export-options.tsx @@ -42,66 +42,73 @@ interface ExportOptionsProps { onOptionsChange: (options: ExportOptionsType) => void; } -const SHEET_OPTIONS = [ +const SHEET_GROUPS = [ { - key: "includeChargesSheet" as keyof ExportOptionsType, - name: "Charges & Fees", - description: "Separate sheet with all transaction charges and fees", + label: "Data", + description: "Split your transactions into separate sheets", + options: [ + { + key: "includeMoneyInSheet" as keyof ExportOptionsType, + name: "Money In", + description: "All transactions where money was received", + }, + { + key: "includeMoneyOutSheet" as keyof ExportOptionsType, + name: "Money Out", + description: "All transactions where money was spent", + }, + { + key: "includeChargesSheet" as keyof ExportOptionsType, + name: "Charges & Fees", + description: "All M-PESA transaction charges and fees", + }, + ], }, { - key: "includeSummarySheet" as keyof ExportOptionsType, - name: "Financial Summary", - description: - "Comprehensive financial analysis with cash flow, spending patterns, and insights", - }, - { - key: "includeBreakdownSheet" as keyof ExportOptionsType, - name: "Monthly & Weekly", - description: - "Pivot-like table with monthly and weekly aggregations showing inflows, outflows, net change, and average transaction size", - }, - { - key: "includeDailyBalanceSheet" as keyof ExportOptionsType, - name: "Daily Balance", - description: - "Day-by-day balance tracker showing highest and lowest balances with spending pattern insights", - }, - { - key: "includeAmountDistributionSheet" as keyof ExportOptionsType, - name: "Amount Distribution", - description: - "Groups transactions by amount ranges (e.g., <100 KES, 100-500 KES, >500 KES), showing counts, totals, and percentages for inflows and outflows separately.", - }, - { - key: "includeTopContactsSheet" as keyof ExportOptionsType, - name: "Top Contacts", - description: - "Top 20 people/entities you send money to and receive money from, with totals and transaction counts.", - }, - { - key: "includeMoneyInSheet" as keyof ExportOptionsType, - name: "Money In", - description: "Separate sheet with all transactions where money was received", - }, - { - key: "includeMoneyOutSheet" as keyof ExportOptionsType, - name: "Money Out", - description: "Separate sheet with all transactions where money was spent", - }, - { - key: "includeRecurringTransactionsSheet" as keyof ExportOptionsType, - name: "Recurring", - description: - "Detects counterparties you transact with repeatedly (3+ times), shows frequency, amount pattern, and predicts the next expected transaction date", - }, - { - key: "includeTimeOfDaySheet" as keyof ExportOptionsType, - name: "Time of Day", - description: - "Breaks down transactions by hour of day and time period (Night/Morning/Afternoon/Evening), showing money in, money out, and net flow per slot", + label: "Analytics", + description: "Summaries and patterns across your transactions", + options: [ + { + key: "includeSummarySheet" as keyof ExportOptionsType, + name: "Financial Summary", + description: "Cash flow, spending patterns, and key financial insights", + }, + { + key: "includeBreakdownSheet" as keyof ExportOptionsType, + name: "Monthly & Weekly", + description: "Inflows, outflows, and net change aggregated by month and week", + }, + { + key: "includeDailyBalanceSheet" as keyof ExportOptionsType, + name: "Daily Balance", + description: "Day-by-day balance tracker with highest and lowest points", + }, + { + key: "includeTopContactsSheet" as keyof ExportOptionsType, + name: "Top Contacts", + description: "Top 20 people/entities you send and receive money from", + }, + { + key: "includeRecurringTransactionsSheet" as keyof ExportOptionsType, + name: "Recurring", + description: "Counterparties you transact with 3+ times, with predicted next date", + }, + { + key: "includeAmountDistributionSheet" as keyof ExportOptionsType, + name: "Amount Distribution", + description: "Transactions grouped by amount ranges (e.g. <100, 100–500, >500 KES)", + }, + { + key: "includeTimeOfDaySheet" as keyof ExportOptionsType, + name: "Time of Day", + description: "Transaction activity by hour — night, morning, afternoon, evening", + }, + ], }, ]; +const SHEET_OPTIONS = SHEET_GROUPS.flatMap((g) => g.options); + export default function ExportOptions({ exportFormat, exportOptions, @@ -392,7 +399,7 @@ export default function ExportOptions({ {/* ── Additional Sheets (XLSX only) ───────────── */} {exportFormat === ExportFormat.XLSX && ( -
+
-
- {SHEET_OPTIONS.map((option) => { - const isSelected = Boolean(exportOptions[option.key]); - return ( - - - handleOptionChange(option.key, !isSelected) - } - className={cn( - "inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-all cursor-pointer select-none", - isSelected - ? "bg-primary text-primary-foreground border-primary" - : "border-border text-muted-foreground hover:border-primary/60 hover:text-foreground" - )} - > - {option.name} - - } - /> - -

{option.description}

-
-
- ); - })} +
+ {SHEET_GROUPS.map((group) => ( +
+

{group.label}

+
+ {group.options.map((option) => { + const isSelected = Boolean(exportOptions[option.key]); + return ( + + handleOptionChange(option.key, !isSelected)} + className={cn( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-all cursor-pointer select-none", + isSelected + ? "bg-primary text-primary-foreground border-primary" + : "border-border text-muted-foreground hover:border-primary/60 hover:text-foreground" + )} + > + {option.name} + + } + /> + +

{option.description}

+
+
+ ); + })} +
+
+ ))}
)} diff --git a/src/components/update-checker.tsx b/src/components/update-checker.tsx index 6df6a7b..0963723 100644 --- a/src/components/update-checker.tsx +++ b/src/components/update-checker.tsx @@ -4,6 +4,7 @@ import { relaunch } from "@tauri-apps/plugin-process"; import { ask, message } from "@tauri-apps/plugin-dialog"; import { RefreshCw } from "lucide-react"; import { Button } from "./ui/button"; +import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; import { URLS } from "../constants"; const dismissedVersions = new Set(); @@ -11,11 +12,13 @@ const dismissedVersions = new Set(); interface UpdateCheckerProps { autoCheck?: boolean; showButton?: boolean; + iconOnly?: boolean; } export function UpdateChecker({ autoCheck = false, showButton = false, + iconOnly = false, }: UpdateCheckerProps) { const [isChecking, setIsChecking] = useState(false); @@ -182,6 +185,28 @@ export function UpdateChecker({ } }, [autoCheck, checkForUpdates]); + if (showButton && iconOnly) { + return ( + + checkForUpdates(true)} + disabled={isChecking} + className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50" + aria-label="Check for updates" + > + + + } + /> + +

{isChecking ? "Checking for updates…" : "Check for updates"}

+
+
+ ); + } + if (showButton) { return ( )} From c2d3665793387a56e9137d0408f543c6670dd86e Mon Sep 17 00:00:00 2001 From: David Amunga Date: Wed, 15 Jul 2026 12:17:34 +0300 Subject: [PATCH 4/7] fix: release security audit blockers --- .github/workflows/main-release.yml | 12 +++++++++--- .github/workflows/pr-check.yml | 21 +++++++++++++++------ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/.github/workflows/main-release.yml b/.github/workflows/main-release.yml index c32e7d0..364bdc4 100644 --- a/.github/workflows/main-release.yml +++ b/.github/workflows/main-release.yml @@ -83,9 +83,15 @@ jobs: VALIDATION_PASSED="true" VALIDATION_ERRORS="" - if ! pnpm audit --audit-level moderate; then - VALIDATION_ERRORS="${VALIDATION_ERRORS}Security vulnerabilities found. " - VALIDATION_PASSED="false" + AUDIT_OUT=$(pnpm audit --audit-level moderate 2>&1) + AUDIT_EXIT=$? + if [ $AUDIT_EXIT -ne 0 ]; then + if echo "$AUDIT_OUT" | grep -qE "410|ERR_PNPM_AUDIT_BAD_RESPONSE|endpoint is being retired"; then + echo "⚠️ npm audit endpoint unavailable (retired July 2026). Skipping security check." + else + VALIDATION_ERRORS="${VALIDATION_ERRORS}Security vulnerabilities found. " + VALIDATION_PASSED="false" + fi fi VERSION=$(node -p "require('./package.json').version") diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 1516791..87aef6c 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -190,12 +190,21 @@ jobs: if: matrix.test_type == 'security' run: | echo "🔒 Running security audit..." - pnpm audit --audit-level moderate || { - echo "⚠️ Security vulnerabilities found. Please review and fix." - echo "Run 'pnpm audit --fix' to automatically fix issues where possible." - exit 1 - } - echo "✅ No security vulnerabilities found" + AUDIT_OUT=$(pnpm audit --audit-level moderate 2>&1) + AUDIT_EXIT=$? + echo "$AUDIT_OUT" + if [ $AUDIT_EXIT -ne 0 ]; then + if echo "$AUDIT_OUT" | grep -qE "410|ERR_PNPM_AUDIT_BAD_RESPONSE|endpoint is being retired"; then + echo "⚠️ npm audit endpoint unavailable (retired July 2026). Skipping." + echo "💡 Dependabot alerts remain active for ongoing security coverage." + else + echo "⚠️ Security vulnerabilities found. Please review and fix." + echo "Run 'pnpm audit --fix' to automatically fix issues where possible." + exit 1 + fi + else + echo "✅ No security vulnerabilities found" + fi release-branch-validation: name: Release Branch Validation From c2abd6258b6b5141ced00f3ca78683bdbfbfb423 Mon Sep 17 00:00:00 2001 From: David Amunga Date: Wed, 15 Jul 2026 12:23:32 +0300 Subject: [PATCH 5/7] fix: main release check --- .github/workflows/main-release.yml | 4 ++-- .github/workflows/pr-check.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main-release.yml b/.github/workflows/main-release.yml index 364bdc4..0eb2a9c 100644 --- a/.github/workflows/main-release.yml +++ b/.github/workflows/main-release.yml @@ -83,8 +83,8 @@ jobs: VALIDATION_PASSED="true" VALIDATION_ERRORS="" - AUDIT_OUT=$(pnpm audit --audit-level moderate 2>&1) - AUDIT_EXIT=$? + AUDIT_EXIT=0 + AUDIT_OUT=$(pnpm audit --audit-level moderate 2>&1) || AUDIT_EXIT=$? if [ $AUDIT_EXIT -ne 0 ]; then if echo "$AUDIT_OUT" | grep -qE "410|ERR_PNPM_AUDIT_BAD_RESPONSE|endpoint is being retired"; then echo "⚠️ npm audit endpoint unavailable (retired July 2026). Skipping security check." diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 87aef6c..5e81424 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -190,8 +190,8 @@ jobs: if: matrix.test_type == 'security' run: | echo "🔒 Running security audit..." - AUDIT_OUT=$(pnpm audit --audit-level moderate 2>&1) - AUDIT_EXIT=$? + AUDIT_EXIT=0 + AUDIT_OUT=$(pnpm audit --audit-level moderate 2>&1) || AUDIT_EXIT=$? echo "$AUDIT_OUT" if [ $AUDIT_EXIT -ne 0 ]; then if echo "$AUDIT_OUT" | grep -qE "410|ERR_PNPM_AUDIT_BAD_RESPONSE|endpoint is being retired"; then From 4911d6afc06f4804a48fd24f4e0d228503d1f626 Mon Sep 17 00:00:00 2001 From: David Amunga Date: Wed, 15 Jul 2026 12:34:22 +0300 Subject: [PATCH 6/7] fix: window height dead space --- src/App.tsx | 108 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 76 insertions(+), 32 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index c884bdf..fa58543 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -495,9 +495,9 @@ function App() {
{/* ── Stats hero ──────────────────────────────────── */} -
- -

- transaction{statements[0].transactions.length !== 1 ? "s" : ""} extracted - {files.length > 1 ? ` from ${files.length} statements` : ""} -

- {statements[0].totalCharges > 0 && ( -

- KES {statements[0].totalCharges.toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} in charges -

- )} -
+ {(() => { + const txs = statements.flatMap(s => s.transactions); + const dates = txs + .map(t => new Date(t.completionTime)) + .filter(d => !isNaN(d.getTime())); + const minDate = dates.length ? new Date(Math.min(...dates.map(d => d.getTime()))) : null; + const maxDate = dates.length ? new Date(Math.max(...dates.map(d => d.getTime()))) : null; + const fmt = (d: Date) => d.toLocaleDateString("en-US", { month: "short", year: "numeric" }); + const dateRange = minDate && maxDate + ? fmt(minDate) === fmt(maxDate) ? fmt(minDate) : `${fmt(minDate)} – ${fmt(maxDate)}` + : null; + + const totalIn = txs.reduce((s, t) => s + (t.paidIn ?? 0), 0); + const totalOut = txs.reduce((s, t) => s + (t.withdrawn ?? 0), 0); + const totalCharges = statements.reduce((s, st) => s + st.totalCharges, 0); + + const abbr = (n: number) => { + if (n >= 1_000_000) return `${(n / 1_000_000).toLocaleString("en-US", { maximumFractionDigits: 2 })}M`; + if (n >= 1_000) return `${(n / 1_000).toLocaleString("en-US", { maximumFractionDigits: 1 })}K`; + return n.toLocaleString("en-US", { maximumFractionDigits: 0 }); + }; + + return ( +
+ +

+ transaction{statements[0].transactions.length !== 1 ? "s" : ""} extracted + {files.length > 1 ? ` from ${files.length} statements` : ""} +

+ {dateRange && ( +

{dateRange}

+ )} + {(totalIn > 0 || totalOut > 0) && ( +
+ {totalIn > 0 && ( + + ↑ KES {abbr(totalIn)} + + )} + {totalIn > 0 && totalOut > 0 && ( + · + )} + {totalOut > 0 && ( + + ↓ KES {abbr(totalOut)} + + )} +
+ )} + {totalCharges > 0 && ( +

+ KES {totalCharges.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} in charges +

+ )} +
+ ); + })()} {/* ── Primary export action ───────────────────────── */}
-