From 6ed99b68f4eec48d01a8190a7758235f1ae02538 Mon Sep 17 00:00:00 2001 From: whekin Date: Wed, 11 Mar 2026 15:33:19 +0400 Subject: [PATCH] feat(miniapp): add finance overview visualizations --- apps/miniapp/src/App.tsx | 353 +++++++++++++++++++++++++++++++++---- apps/miniapp/src/i18n.ts | 20 +++ apps/miniapp/src/index.css | 177 +++++++++++++++++++ 3 files changed, 512 insertions(+), 38 deletions(-) diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index 0eb700a..adbb127 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -100,6 +100,8 @@ type PaymentDraft = { currency: 'USD' | 'GEL' } +const chartPalette = ['#f7b389', '#6fd3c0', '#f06a8d', '#94a8ff', '#f3d36f', '#7dc96d'] as const + const demoSession: Extract = { status: 'ready', mode: 'demo', @@ -160,14 +162,6 @@ function joinDeepLink(): string | null { return `https://t.me/${context.botUsername}?start=join_${encodeURIComponent(context.joinToken)}` } -function dashboardMemberCount(dashboard: MiniAppDashboard | null): string { - return dashboard ? String(dashboard.members.length) : '—' -} - -function dashboardLedgerCount(dashboard: MiniAppDashboard | null): string { - return dashboard ? String(dashboard.ledger.length) : '—' -} - function defaultCyclePeriod(): string { return new Date().toISOString().slice(0, 7) } @@ -193,6 +187,10 @@ function minorToMajorString(value: bigint): string { return `${negative ? '-' : ''}${whole.toString()}.${fraction}` } +function absoluteMinor(value: bigint): bigint { + return value < 0n ? -value : value +} + function memberBaseDueMajor(member: MiniAppDashboard['members'][number]): string { return minorToMajorString( majorStringToMinor(member.rentShareMajor) + majorStringToMinor(member.utilityShareMajor) @@ -412,6 +410,147 @@ function App() { const paymentLedger = createMemo(() => (dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'payment') ) + const utilityTotalMajor = createMemo(() => + minorToMajorString( + utilityLedger().reduce((sum, entry) => sum + majorStringToMinor(entry.displayAmountMajor), 0n) + ) + ) + const purchaseTotalMajor = createMemo(() => + minorToMajorString( + purchaseLedger().reduce( + (sum, entry) => sum + majorStringToMinor(entry.displayAmountMajor), + 0n + ) + ) + ) + const memberBalanceVisuals = createMemo(() => { + const data = dashboard() + if (!data) { + return [] + } + + const totals = data.members.map((member) => { + const rentMinor = absoluteMinor(majorStringToMinor(member.rentShareMajor)) + const utilityMinor = absoluteMinor(majorStringToMinor(member.utilityShareMajor)) + const purchaseMinor = absoluteMinor(majorStringToMinor(member.purchaseOffsetMajor)) + + return { + member, + totalMinor: rentMinor + utilityMinor + purchaseMinor, + segments: [ + { + key: 'rent', + label: copy().shareRent, + amountMajor: member.rentShareMajor, + amountMinor: rentMinor + }, + { + key: 'utilities', + label: copy().shareUtilities, + amountMajor: member.utilityShareMajor, + amountMinor: utilityMinor + }, + { + key: + majorStringToMinor(member.purchaseOffsetMajor) < 0n + ? 'purchase-credit' + : 'purchase-debit', + label: copy().shareOffset, + amountMajor: member.purchaseOffsetMajor, + amountMinor: purchaseMinor + } + ] + } + }) + + const maxTotalMinor = totals.reduce( + (max, item) => (item.totalMinor > max ? item.totalMinor : max), + 0n + ) + + return totals + .sort((left, right) => { + const leftRemaining = majorStringToMinor(left.member.remainingMajor) + const rightRemaining = majorStringToMinor(right.member.remainingMajor) + + if (rightRemaining === leftRemaining) { + return left.member.displayName.localeCompare(right.member.displayName) + } + + return rightRemaining > leftRemaining ? 1 : -1 + }) + .map((item) => ({ + ...item, + barWidthPercent: + maxTotalMinor > 0n ? (Number(item.totalMinor) / Number(maxTotalMinor)) * 100 : 0, + segments: item.segments.map((segment) => ({ + ...segment, + widthPercent: + item.totalMinor > 0n ? (Number(segment.amountMinor) / Number(item.totalMinor)) * 100 : 0 + })) + })) + }) + const purchaseInvestmentChart = createMemo(() => { + const data = dashboard() + if (!data) { + return { + totalMajor: '0.00', + slices: [] + } + } + + const membersById = new Map(data.members.map((member) => [member.memberId, member.displayName])) + const totals = new Map() + + for (const entry of purchaseLedger()) { + const key = entry.memberId ?? entry.actorDisplayName ?? entry.id + const label = + (entry.memberId ? membersById.get(entry.memberId) : null) ?? + entry.actorDisplayName ?? + copy().ledgerActorFallback + const current = totals.get(key) ?? { + label, + amountMinor: 0n + } + + totals.set(key, { + label, + amountMinor: + current.amountMinor + absoluteMinor(majorStringToMinor(entry.displayAmountMajor)) + }) + } + + const items = [...totals.entries()] + .map(([key, value], index) => ({ + key, + label: value.label, + amountMinor: value.amountMinor, + amountMajor: minorToMajorString(value.amountMinor), + color: chartPalette[index % chartPalette.length]! + })) + .filter((item) => item.amountMinor > 0n) + .sort((left, right) => (right.amountMinor > left.amountMinor ? 1 : -1)) + + const totalMinor = items.reduce((sum, item) => sum + item.amountMinor, 0n) + const circumference = 2 * Math.PI * 42 + let offset = 0 + + return { + totalMajor: minorToMajorString(totalMinor), + slices: items.map((item) => { + const ratio = totalMinor > 0n ? Number(item.amountMinor) / Number(totalMinor) : 0 + const dash = ratio * circumference + const slice = { + ...item, + percentage: Math.round(ratio * 100), + dasharray: `${dash} ${Math.max(circumference - dash, 0)}`, + dashoffset: `${-offset}` + } + offset += dash + return slice + }) + } + }) const webApp = getTelegramWebApp() function ledgerTitle(entry: MiniAppDashboard['ledger'][number]): string { @@ -1585,6 +1724,142 @@ function App() { })) } + function renderFinanceSummaryCards(data: MiniAppDashboard): JSX.Element { + return ( + <> +
+ {copy().remainingLabel} + + {data.totalRemainingMajor} {data.currency} + +
+
+ {copy().shareRent} + + {data.rentDisplayAmountMajor} {data.currency} + +
+
+ {copy().shareUtilities} + + {utilityTotalMajor()} {data.currency} + +
+
+ {copy().purchasesTitle} + + {purchaseTotalMajor()} {data.currency} + +
+ + ) + } + + function renderFinanceVisuals(data: MiniAppDashboard): JSX.Element { + const purchaseChart = purchaseInvestmentChart() + + return ( + <> +
+
+ {copy().financeVisualsTitle} + + {copy().membersCount}: {String(data.members.length)} + +
+

{copy().financeVisualsBody}

+
+ {memberBalanceVisuals().map((item) => ( +
+
+ {item.member.displayName} + + {item.member.remainingMajor} {data.currency} + +
+
+
+ {item.segments.map((segment) => ( + + ))} +
+
+
+ {item.segments.map((segment) => ( + + {segment.label}: {segment.amountMajor} {data.currency} + + ))} +
+
+ ))} +
+
+ +
+
+ {copy().purchaseInvestmentsTitle} + + {copy().purchaseTotalLabel}: {purchaseChart.totalMajor} {data.currency} + +
+

{copy().purchaseInvestmentsBody}

+ {purchaseChart.slices.length === 0 ? ( +

{copy().purchaseInvestmentsEmpty}

+ ) : ( +
+
+ +
+ {copy().purchaseTotalLabel} + + {purchaseChart.totalMajor} {data.currency} + +
+
+
+ {purchaseChart.slices.map((slice) => ( +
+
+ + {slice.label} +
+

+ {slice.amountMajor} {data.currency} · {copy().purchaseShareLabel}{' '} + {slice.percentage}% +

+
+ ))} +
+
+ )} +
+ + ) + } + const renderPanel = () => { switch (activeNav()) { case 'balances': @@ -1638,6 +1913,8 @@ function App() { ) : null} +
{renderFinanceSummaryCards(data)}
+ {renderFinanceVisuals(data)}
{copy().householdBalancesTitle} @@ -3132,36 +3409,30 @@ function App() { default: return (
-
- {copy().totalDue} - - {dashboard() ? `${dashboard()!.totalDueMajor} ${dashboard()!.currency}` : '—'} - -
-
- {copy().paidLabel} - - {dashboard() ? `${dashboard()!.totalPaidMajor} ${dashboard()!.currency}` : '—'} - -
-
- {copy().remainingLabel} - - {dashboard() ? `${dashboard()!.totalRemainingMajor} ${dashboard()!.currency}` : '—'} - -
-
- {copy().membersCount} - {dashboardMemberCount(dashboard())} -
-
- {copy().ledgerEntries} - {dashboardLedgerCount(dashboard())} -
-
- {copy().purchasesTitle} - {String(purchaseLedger().length)} -
+ +
+ {copy().remainingLabel} + +
+
+ {copy().shareRent} + +
+
+ {copy().shareUtilities} + +
+
+ {copy().purchasesTitle} + +
+ + } + render={(data) => renderFinanceSummaryCards(data)} + /> {readySession()?.member.isAdmin ? (
{copy().pendingRequests} @@ -3232,6 +3503,12 @@ function App() {
)} + renderFinanceVisuals(data)} + /> +
{copy().latestActivityTitle} diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index 033073a..24965bd 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -57,6 +57,16 @@ export const dictionary = { finalDue: 'Final due', householdBalancesTitle: 'Household balances', householdBalancesBody: 'Everyone’s current split for this cycle.', + financeVisualsTitle: 'Visual balance split', + financeVisualsBody: + 'Use the bars to see how rent, utilities, and shared-buy adjustments shape each member balance.', + purchaseInvestmentsTitle: 'Who fronted shared purchases', + purchaseInvestmentsBody: + 'This donut shows how much each member invested into shared purchases this cycle.', + purchaseInvestmentsEmpty: + 'Purchase contributions will appear here after shared buys are logged.', + purchaseShareLabel: 'Share', + purchaseTotalLabel: 'Total shared buys', purchasesTitle: 'Shared purchases', purchasesEmpty: 'No shared purchases recorded for this cycle yet.', utilityLedgerTitle: 'Utility bills', @@ -242,6 +252,16 @@ export const dictionary = { finalDue: 'Итог к оплате', householdBalancesTitle: 'Баланс household', householdBalancesBody: 'Текущий расклад по всем участникам за этот цикл.', + financeVisualsTitle: 'Визуальный разбор баланса', + financeVisualsBody: + 'Полосы показывают, как аренда, коммуналка и поправка на общие покупки формируют баланс каждого участника.', + purchaseInvestmentsTitle: 'Кто оплачивал общие покупки', + purchaseInvestmentsBody: + 'Кольцевая диаграмма показывает, сколько каждый участник вложил в общие покупки в этом цикле.', + purchaseInvestmentsEmpty: + 'Вклады в общие покупки появятся здесь после первых записанных покупок.', + purchaseShareLabel: 'Доля', + purchaseTotalLabel: 'Всего общих покупок', purchasesTitle: 'Общие покупки', purchasesEmpty: 'Пока нет общих покупок в этом цикле.', utilityLedgerTitle: 'Коммунальные платежи', diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index 6151e91..1958e14 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -308,6 +308,170 @@ button { margin-top: 12px; } +.member-visual-list { + display: grid; + gap: 12px; + margin-top: 14px; +} + +.member-visual-card { + border: 1px solid rgb(255 255 255 / 0.08); + border-radius: 18px; + padding: 14px; + background: rgb(255 255 255 / 0.02); +} + +.member-visual-card header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + margin-bottom: 10px; +} + +.member-visual-bar { + margin-top: 10px; +} + +.member-visual-bar__track { + display: flex; + min-height: 14px; + border-radius: 999px; + overflow: hidden; + background: rgb(255 255 255 / 0.04); +} + +.member-visual-bar__segment--rent { + background: #f7b389; +} + +.member-visual-bar__segment--utilities { + background: #6fd3c0; +} + +.member-visual-bar__segment--purchase-debit { + background: #f06a8d; +} + +.member-visual-bar__segment--purchase-credit { + background: #94a8ff; +} + +.member-visual-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.member-visual-chip { + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: 999px; + padding: 6px 10px; + background: rgb(255 255 255 / 0.05); + font-size: 0.85rem; +} + +.member-visual-chip--rent { + border: 1px solid rgb(247 179 137 / 0.35); +} + +.member-visual-chip--utilities { + border: 1px solid rgb(111 211 192 / 0.35); +} + +.member-visual-chip--purchase-debit { + border: 1px solid rgb(240 106 141 / 0.35); +} + +.member-visual-chip--purchase-credit { + border: 1px solid rgb(148 168 255 / 0.35); +} + +.purchase-chart { + display: grid; + gap: 16px; + margin-top: 14px; +} + +.purchase-chart__figure { + position: relative; + width: min(220px, 100%); + justify-self: center; +} + +.purchase-chart__donut { + width: 100%; + overflow: visible; +} + +.purchase-chart__ring, +.purchase-chart__slice { + fill: none; + stroke-width: 16; +} + +.purchase-chart__ring { + stroke: rgb(255 255 255 / 0.08); +} + +.purchase-chart__slice { + transform: rotate(-90deg); + transform-origin: 50% 50%; + stroke-linecap: butt; +} + +.purchase-chart__center { + position: absolute; + inset: 0; + display: grid; + place-content: center; + gap: 4px; + text-align: center; +} + +.purchase-chart__center span { + color: #c6c2bb; + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.purchase-chart__center strong { + font-family: 'Space Grotesk', 'IBM Plex Sans', sans-serif; + font-size: clamp(1.15rem, 4vw, 1.5rem); +} + +.purchase-chart__legend { + display: grid; + gap: 10px; +} + +.purchase-chart__legend-item { + border: 1px solid rgb(255 255 255 / 0.08); + border-radius: 16px; + padding: 12px; + background: rgb(255 255 255 / 0.02); +} + +.purchase-chart__legend-item div { + display: flex; + align-items: center; + gap: 10px; +} + +.purchase-chart__legend-item p { + margin-top: 8px; +} + +.purchase-chart__legend-swatch { + width: 12px; + height: 12px; + border-radius: 999px; + flex: 0 0 auto; +} + .stat-card span { color: #c6c2bb; font-size: 0.82rem; @@ -503,6 +667,15 @@ button { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .purchase-chart { + grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); + align-items: center; + } + + .purchase-chart__legend { + align-self: stretch; + } + .admin-summary-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } @@ -560,6 +733,10 @@ button { grid-template-columns: minmax(0, 1fr); } + .member-visual-card header { + grid-template-columns: minmax(0, 1fr); + } + .admin-section__header { align-items: start; }