|
137 | 137 | return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); |
138 | 138 | } |
139 | 139 |
|
| 140 | + // Round a PNK amount to 2 decimal places. |
| 141 | + function round2(n) { |
| 142 | + return Math.round(n * 100) / 100; |
| 143 | + } |
| 144 | + |
| 145 | + // Sort month labels newest-first, keeping the combined "2021-01 & 02" bucket last. |
| 146 | + function compareMonths(a, b) { |
| 147 | + if (a === "2021-01 & 02") return 1; |
| 148 | + if (b === "2021-01 & 02") return -1; |
| 149 | + return b.localeCompare(a); |
| 150 | + } |
| 151 | + |
| 152 | + // Build a { Month, Mainnet PNK, Gnosis PNK, Total PNK } row summing all wallets for a month. |
| 153 | + function monthTotalRow(label, bucket) { |
| 154 | + let m = 0, |
| 155 | + g = 0; |
| 156 | + for (const v of Object.values(bucket.mainnet)) m += v; |
| 157 | + for (const v of Object.values(bucket.gnosis)) g += v; |
| 158 | + return { |
| 159 | + Month: label, |
| 160 | + "Mainnet PNK": round2(m), |
| 161 | + "Gnosis PNK": round2(g), |
| 162 | + "Total PNK": round2(m + g), |
| 163 | + }; |
| 164 | + } |
| 165 | + |
| 166 | + // Build sorted per-wallet { Address, Mainnet PNK, Gnosis PNK, Total PNK } rows for one month. |
| 167 | + function monthWalletRows(bucket) { |
| 168 | + const addrs = new Set([...Object.keys(bucket.mainnet), ...Object.keys(bucket.gnosis)]); |
| 169 | + return [...addrs] |
| 170 | + .map((addr) => { |
| 171 | + const m = bucket.mainnet[addr] || 0; |
| 172 | + const g = bucket.gnosis[addr] || 0; |
| 173 | + return { |
| 174 | + Address: addr, |
| 175 | + "Mainnet PNK": round2(m), |
| 176 | + "Gnosis PNK": round2(g), |
| 177 | + "Total PNK": round2(m + g), |
| 178 | + }; |
| 179 | + }) |
| 180 | + .sort((a, b) => b["Total PNK"] - a["Total PNK"]); |
| 181 | + } |
| 182 | + |
| 183 | + // Build the 4-column spec shared by every tab: a left-aligned first column, |
| 184 | + // the two right-aligned PNK columns, and a right-aligned final total column. |
| 185 | + function pnkColumns(firstKey, lastKey) { |
| 186 | + return [ |
| 187 | + { key: firstKey, label: firstKey, align: "left" }, |
| 188 | + { key: "Mainnet PNK", label: "Mainnet PNK", align: "right" }, |
| 189 | + { key: "Gnosis PNK", label: "Gnosis PNK", align: "right" }, |
| 190 | + { key: lastKey, label: lastKey, align: "right" }, |
| 191 | + ]; |
| 192 | + } |
| 193 | + |
| 194 | + // Append a worksheet built from row objects, with the given column widths. |
| 195 | + function addSheet(wb, name, rows, widths) { |
| 196 | + const ws = XLSX.utils.json_to_sheet(rows); |
| 197 | + ws["!cols"] = widths.map((wch) => ({ wch })); |
| 198 | + XLSX.utils.book_append_sheet(wb, ws, name); |
| 199 | + } |
| 200 | + |
140 | 201 | // Spreadsheet-like table component |
141 | 202 | const PAGE_SIZE = 100; |
142 | 203 |
|
143 | | - function SpreadsheetTable({ rows, columns }) { |
| 204 | + function SpreadsheetTable({ rows, columns, noun = "address", nounPlural = "addresses" }) { |
144 | 205 | const [sortCol, setSortCol] = useState(null); |
145 | 206 | const [sortAsc, setSortAsc] = useState(false); |
146 | 207 | const [search, setSearch] = useState(""); |
147 | 208 | const [page, setPage] = useState(0); |
148 | 209 |
|
| 210 | + const filterKey = columns[0].key; |
149 | 211 | const filtered = useMemo(() => { |
150 | 212 | if (!search) return rows; |
151 | 213 | const q = search.toLowerCase(); |
152 | | - return rows.filter((r) => r.Address.toLowerCase().includes(q)); |
153 | | - }, [rows, search]); |
| 214 | + return rows.filter((r) => |
| 215 | + String(r[filterKey] ?? "") |
| 216 | + .toLowerCase() |
| 217 | + .includes(q) |
| 218 | + ); |
| 219 | + }, [rows, search, filterKey]); |
154 | 220 |
|
155 | 221 | const sorted = useMemo(() => { |
156 | 222 | if (sortCol === null) return filtered; |
|
185 | 251 | <input |
186 | 252 | className="search-input" |
187 | 253 | type="text" |
188 | | - placeholder="Search address..." |
| 254 | + placeholder={"Search " + noun + "..."} |
189 | 255 | value={search} |
190 | 256 | onChange={(e) => setSearch(e.target.value)} |
191 | 257 | style={{ |
|
201 | 267 | }} |
202 | 268 | /> |
203 | 269 | <span style={{ fontSize: 11, color: "#6c6c8a", whiteSpace: "nowrap" }}> |
204 | | - {filtered.length.toLocaleString()} address{filtered.length !== 1 ? "es" : ""} |
| 270 | + {filtered.length.toLocaleString()} {filtered.length !== 1 ? nounPlural : noun} |
205 | 271 | </span> |
206 | 272 | </div> |
207 | 273 | <div style={{ overflowX: "auto", borderRadius: 8, border: "1px solid rgba(108,92,231,0.15)" }}> |
|
466 | 532 | setProgress((p) => ({ ...p, done: Math.min(i + CONCURRENCY, total) })); |
467 | 533 | } |
468 | 534 |
|
469 | | - const sorted = Object.keys(md).sort((a, b) => { |
470 | | - if (a === "2021-01 & 02") return 1; |
471 | | - if (b === "2021-01 & 02") return -1; |
472 | | - return b.localeCompare(a); |
473 | | - }); |
| 535 | + const sorted = Object.keys(md).sort(compareMonths); |
474 | 536 |
|
475 | 537 | setMonthData(md); |
476 | 538 | setGrandTotals(gt); |
477 | | - setTabList(["Summary", ...sorted]); |
| 539 | + setTabList(["Summary", "Monthly Totals", ...sorted]); |
478 | 540 | setActiveTab("Summary"); |
479 | 541 | setErrors(errs); |
480 | 542 | setPhase("done"); |
481 | 543 | } |
482 | 544 |
|
| 545 | + // Months sorted newest-first (matches the tab order) |
| 546 | + const sortedMonths = useMemo(() => { |
| 547 | + if (!monthData) return []; |
| 548 | + return Object.keys(monthData).sort(compareMonths); |
| 549 | + }, [monthData]); |
| 550 | + |
483 | 551 | // Build rows for current tab |
484 | 552 | const currentRows = useMemo(() => { |
485 | 553 | if (!grandTotals || !monthData) return []; |
486 | 554 | if (activeTab === "Summary") { |
487 | 555 | return Object.entries(grandTotals) |
488 | 556 | .map(([addr, t]) => ({ |
489 | 557 | Address: addr, |
490 | | - "Mainnet PNK": Math.round(t.mainnet * 100) / 100, |
491 | | - "Gnosis PNK": Math.round(t.gnosis * 100) / 100, |
492 | | - "Grand Total": Math.round((t.mainnet + t.gnosis) * 100) / 100, |
| 558 | + "Mainnet PNK": round2(t.mainnet), |
| 559 | + "Gnosis PNK": round2(t.gnosis), |
| 560 | + "Grand Total": round2(t.mainnet + t.gnosis), |
493 | 561 | })) |
494 | 562 | .sort((a, b) => b["Grand Total"] - a["Grand Total"]); |
495 | 563 | } |
496 | | - const { mainnet, gnosis } = monthData[activeTab] || { mainnet: {}, gnosis: {} }; |
497 | | - const allAddrs = new Set([...Object.keys(mainnet), ...Object.keys(gnosis)]); |
498 | | - return [...allAddrs] |
499 | | - .map((addr) => { |
500 | | - const m = mainnet[addr] || 0; |
501 | | - const g = gnosis[addr] || 0; |
502 | | - return { |
503 | | - Address: addr, |
504 | | - "Mainnet PNK": Math.round(m * 100) / 100, |
505 | | - "Gnosis PNK": Math.round(g * 100) / 100, |
506 | | - "Total PNK": Math.round((m + g) * 100) / 100, |
507 | | - }; |
508 | | - }) |
509 | | - .sort((a, b) => (b["Total PNK"] || 0) - (a["Total PNK"] || 0)); |
510 | | - }, [activeTab, grandTotals, monthData]); |
| 564 | + if (activeTab === "Monthly Totals") { |
| 565 | + return sortedMonths.map((label) => monthTotalRow(label, monthData[label])); |
| 566 | + } |
| 567 | + return monthWalletRows(monthData[activeTab] || { mainnet: {}, gnosis: {} }); |
| 568 | + }, [activeTab, grandTotals, monthData, sortedMonths]); |
511 | 569 |
|
512 | 570 | const currentColumns = useMemo(() => { |
513 | | - if (activeTab === "Summary") |
514 | | - return [ |
515 | | - { key: "Address", label: "Address", align: "left" }, |
516 | | - { key: "Mainnet PNK", label: "Mainnet PNK", align: "right" }, |
517 | | - { key: "Gnosis PNK", label: "Gnosis PNK", align: "right" }, |
518 | | - { key: "Grand Total", label: "Grand Total", align: "right" }, |
519 | | - ]; |
520 | | - return [ |
521 | | - { key: "Address", label: "Address", align: "left" }, |
522 | | - { key: "Mainnet PNK", label: "Mainnet PNK", align: "right" }, |
523 | | - { key: "Gnosis PNK", label: "Gnosis PNK", align: "right" }, |
524 | | - { key: "Total PNK", label: "Total PNK", align: "right" }, |
525 | | - ]; |
| 571 | + if (activeTab === "Summary") return pnkColumns("Address", "Grand Total"); |
| 572 | + if (activeTab === "Monthly Totals") return pnkColumns("Month", "Total PNK"); |
| 573 | + return pnkColumns("Address", "Total PNK"); |
526 | 574 | }, [activeTab]); |
527 | 575 |
|
528 | 576 | function downloadXLSX() { |
|
532 | 580 | const summaryRows = Object.entries(grandTotals) |
533 | 581 | .map(([addr, t]) => ({ |
534 | 582 | Address: addr, |
535 | | - "Total Mainnet PNK": Math.round(t.mainnet * 100) / 100, |
536 | | - "Total Gnosis PNK": Math.round(t.gnosis * 100) / 100, |
537 | | - "Grand Total PNK": Math.round((t.mainnet + t.gnosis) * 100) / 100, |
| 583 | + "Total Mainnet PNK": round2(t.mainnet), |
| 584 | + "Total Gnosis PNK": round2(t.gnosis), |
| 585 | + "Grand Total PNK": round2(t.mainnet + t.gnosis), |
538 | 586 | })) |
539 | 587 | .sort((a, b) => b["Grand Total PNK"] - a["Grand Total PNK"]); |
540 | | - const sws = XLSX.utils.json_to_sheet(summaryRows); |
541 | | - sws["!cols"] = [{ wch: 44 }, { wch: 20 }, { wch: 20 }, { wch: 20 }]; |
542 | | - XLSX.utils.book_append_sheet(wb, sws, "Summary"); |
543 | | - |
544 | | - for (const label of tabList.slice(1)) { |
545 | | - const { mainnet, gnosis } = monthData[label]; |
546 | | - const allAddrs = new Set([...Object.keys(mainnet), ...Object.keys(gnosis)]); |
547 | | - if (allAddrs.size === 0) continue; |
548 | | - const rows = [...allAddrs] |
549 | | - .map((addr) => { |
550 | | - const m = mainnet[addr] || 0; |
551 | | - const g = gnosis[addr] || 0; |
552 | | - return { |
553 | | - Address: addr, |
554 | | - "Mainnet PNK": Math.round(m * 100) / 100, |
555 | | - "Gnosis PNK": Math.round(g * 100) / 100, |
556 | | - "Total PNK": Math.round((m + g) * 100) / 100, |
557 | | - }; |
558 | | - }) |
559 | | - .sort((a, b) => b["Total PNK"] - a["Total PNK"]); |
560 | | - const ws = XLSX.utils.json_to_sheet(rows); |
561 | | - ws["!cols"] = [{ wch: 44 }, { wch: 18 }, { wch: 18 }, { wch: 18 }]; |
562 | | - XLSX.utils.book_append_sheet(wb, ws, label.slice(0, 31)); |
| 588 | + addSheet(wb, "Summary", summaryRows, [44, 20, 20, 20]); |
| 589 | + |
| 590 | + const monthlyTotalRows = sortedMonths.map((label) => monthTotalRow(label, monthData[label])); |
| 591 | + addSheet(wb, "Monthly Totals", monthlyTotalRows, [14, 18, 18, 18]); |
| 592 | + |
| 593 | + for (const label of sortedMonths) { |
| 594 | + const rows = monthWalletRows(monthData[label]); |
| 595 | + if (rows.length === 0) continue; |
| 596 | + addSheet(wb, label.slice(0, 31), rows, [44, 18, 18, 18]); |
563 | 597 | } |
564 | 598 |
|
565 | 599 | const buf = XLSX.write(wb, { bookType: "xlsx", type: "array" }); |
|
634 | 668 | )} |
635 | 669 | {phase === "done" && ( |
636 | 670 | <span style={{ fontSize: 10, color: "#6c6c8a" }}> |
637 | | - {Object.keys(grandTotals).length.toLocaleString()} addresses · {tabList.length - 1} months |
| 671 | + {Object.keys(grandTotals).length.toLocaleString()} addresses · {sortedMonths.length} months |
638 | 672 | </span> |
639 | 673 | )} |
640 | 674 | </div> |
|
776 | 810 |
|
777 | 811 | {/* Table */} |
778 | 812 | <div className="table-wrap" style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}> |
779 | | - <SpreadsheetTable rows={currentRows} columns={currentColumns} /> |
| 813 | + <SpreadsheetTable |
| 814 | + rows={currentRows} |
| 815 | + columns={currentColumns} |
| 816 | + noun={activeTab === "Monthly Totals" ? "month" : "address"} |
| 817 | + nounPlural={activeTab === "Monthly Totals" ? "months" : "addresses"} |
| 818 | + /> |
780 | 819 | </div> |
781 | 820 |
|
782 | 821 | {/* Fetch errors */} |
|
0 commit comments