feat(miniapp): add finance overview visualizations

This commit is contained in:
2026-03-11 15:33:19 +04:00
parent a9c59ad0ba
commit 6ed99b68f4
3 changed files with 512 additions and 38 deletions

View File

@@ -100,6 +100,8 @@ type PaymentDraft = {
currency: 'USD' | 'GEL' currency: 'USD' | 'GEL'
} }
const chartPalette = ['#f7b389', '#6fd3c0', '#f06a8d', '#94a8ff', '#f3d36f', '#7dc96d'] as const
const demoSession: Extract<SessionState, { status: 'ready' }> = { const demoSession: Extract<SessionState, { status: 'ready' }> = {
status: 'ready', status: 'ready',
mode: 'demo', mode: 'demo',
@@ -160,14 +162,6 @@ function joinDeepLink(): string | null {
return `https://t.me/${context.botUsername}?start=join_${encodeURIComponent(context.joinToken)}` 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 { function defaultCyclePeriod(): string {
return new Date().toISOString().slice(0, 7) return new Date().toISOString().slice(0, 7)
} }
@@ -193,6 +187,10 @@ function minorToMajorString(value: bigint): string {
return `${negative ? '-' : ''}${whole.toString()}.${fraction}` return `${negative ? '-' : ''}${whole.toString()}.${fraction}`
} }
function absoluteMinor(value: bigint): bigint {
return value < 0n ? -value : value
}
function memberBaseDueMajor(member: MiniAppDashboard['members'][number]): string { function memberBaseDueMajor(member: MiniAppDashboard['members'][number]): string {
return minorToMajorString( return minorToMajorString(
majorStringToMinor(member.rentShareMajor) + majorStringToMinor(member.utilityShareMajor) majorStringToMinor(member.rentShareMajor) + majorStringToMinor(member.utilityShareMajor)
@@ -412,6 +410,147 @@ function App() {
const paymentLedger = createMemo(() => const paymentLedger = createMemo(() =>
(dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'payment') (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<string, { label: string; amountMinor: bigint }>()
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() const webApp = getTelegramWebApp()
function ledgerTitle(entry: MiniAppDashboard['ledger'][number]): string { function ledgerTitle(entry: MiniAppDashboard['ledger'][number]): string {
@@ -1585,6 +1724,142 @@ function App() {
})) }))
} }
function renderFinanceSummaryCards(data: MiniAppDashboard): JSX.Element {
return (
<>
<article class="stat-card">
<span>{copy().remainingLabel}</span>
<strong>
{data.totalRemainingMajor} {data.currency}
</strong>
</article>
<article class="stat-card">
<span>{copy().shareRent}</span>
<strong>
{data.rentDisplayAmountMajor} {data.currency}
</strong>
</article>
<article class="stat-card">
<span>{copy().shareUtilities}</span>
<strong>
{utilityTotalMajor()} {data.currency}
</strong>
</article>
<article class="stat-card">
<span>{copy().purchasesTitle}</span>
<strong>
{purchaseTotalMajor()} {data.currency}
</strong>
</article>
</>
)
}
function renderFinanceVisuals(data: MiniAppDashboard): JSX.Element {
const purchaseChart = purchaseInvestmentChart()
return (
<>
<article class="balance-item balance-item--wide">
<header>
<strong>{copy().financeVisualsTitle}</strong>
<span>
{copy().membersCount}: {String(data.members.length)}
</span>
</header>
<p>{copy().financeVisualsBody}</p>
<div class="member-visual-list">
{memberBalanceVisuals().map((item) => (
<article class="member-visual-card">
<header>
<strong>{item.member.displayName}</strong>
<span class={`balance-status ${memberRemainingClass(item.member)}`}>
{item.member.remainingMajor} {data.currency}
</span>
</header>
<div class="member-visual-bar">
<div
class="member-visual-bar__track"
style={{ width: `${item.barWidthPercent}%` }}
>
{item.segments.map((segment) => (
<span
class={`member-visual-bar__segment member-visual-bar__segment--${segment.key}`}
style={{ width: `${segment.widthPercent}%` }}
/>
))}
</div>
</div>
<div class="member-visual-meta">
{item.segments.map((segment) => (
<span class={`member-visual-chip member-visual-chip--${segment.key}`}>
{segment.label}: {segment.amountMajor} {data.currency}
</span>
))}
</div>
</article>
))}
</div>
</article>
<article class="balance-item balance-item--wide">
<header>
<strong>{copy().purchaseInvestmentsTitle}</strong>
<span>
{copy().purchaseTotalLabel}: {purchaseChart.totalMajor} {data.currency}
</span>
</header>
<p>{copy().purchaseInvestmentsBody}</p>
{purchaseChart.slices.length === 0 ? (
<p>{copy().purchaseInvestmentsEmpty}</p>
) : (
<div class="purchase-chart">
<div class="purchase-chart__figure">
<svg class="purchase-chart__donut" viewBox="0 0 120 120" aria-hidden="true">
<circle class="purchase-chart__ring" cx="60" cy="60" r="42" />
{purchaseChart.slices.map((slice) => (
<circle
class="purchase-chart__slice"
cx="60"
cy="60"
r="42"
stroke={slice.color}
stroke-dasharray={slice.dasharray}
stroke-dashoffset={slice.dashoffset}
/>
))}
</svg>
<div class="purchase-chart__center">
<span>{copy().purchaseTotalLabel}</span>
<strong>
{purchaseChart.totalMajor} {data.currency}
</strong>
</div>
</div>
<div class="purchase-chart__legend">
{purchaseChart.slices.map((slice) => (
<article class="purchase-chart__legend-item">
<div>
<span
class="purchase-chart__legend-swatch"
style={{ 'background-color': slice.color }}
/>
<strong>{slice.label}</strong>
</div>
<p>
{slice.amountMajor} {data.currency} · {copy().purchaseShareLabel}{' '}
{slice.percentage}%
</p>
</article>
))}
</div>
</div>
)}
</article>
</>
)
}
const renderPanel = () => { const renderPanel = () => {
switch (activeNav()) { switch (activeNav()) {
case 'balances': case 'balances':
@@ -1638,6 +1913,8 @@ function App() {
</div> </div>
</article> </article>
) : null} ) : null}
<div class="home-grid home-grid--summary">{renderFinanceSummaryCards(data)}</div>
{renderFinanceVisuals(data)}
<article class="balance-item"> <article class="balance-item">
<header> <header>
<strong>{copy().householdBalancesTitle}</strong> <strong>{copy().householdBalancesTitle}</strong>
@@ -3132,36 +3409,30 @@ function App() {
default: default:
return ( return (
<div class="home-grid home-grid--summary"> <div class="home-grid home-grid--summary">
<article class="stat-card"> <ShowDashboard
<span>{copy().totalDue}</span> dashboard={dashboard()}
<strong> fallback={
{dashboard() ? `${dashboard()!.totalDueMajor} ${dashboard()!.currency}` : '—'} <>
</strong>
</article>
<article class="stat-card">
<span>{copy().paidLabel}</span>
<strong>
{dashboard() ? `${dashboard()!.totalPaidMajor} ${dashboard()!.currency}` : '—'}
</strong>
</article>
<article class="stat-card"> <article class="stat-card">
<span>{copy().remainingLabel}</span> <span>{copy().remainingLabel}</span>
<strong> <strong></strong>
{dashboard() ? `${dashboard()!.totalRemainingMajor} ${dashboard()!.currency}` : '—'}
</strong>
</article> </article>
<article class="stat-card"> <article class="stat-card">
<span>{copy().membersCount}</span> <span>{copy().shareRent}</span>
<strong>{dashboardMemberCount(dashboard())}</strong> <strong></strong>
</article> </article>
<article class="stat-card"> <article class="stat-card">
<span>{copy().ledgerEntries}</span> <span>{copy().shareUtilities}</span>
<strong>{dashboardLedgerCount(dashboard())}</strong> <strong></strong>
</article> </article>
<article class="stat-card"> <article class="stat-card">
<span>{copy().purchasesTitle}</span> <span>{copy().purchasesTitle}</span>
<strong>{String(purchaseLedger().length)}</strong> <strong></strong>
</article> </article>
</>
}
render={(data) => renderFinanceSummaryCards(data)}
/>
{readySession()?.member.isAdmin ? ( {readySession()?.member.isAdmin ? (
<article class="stat-card"> <article class="stat-card">
<span>{copy().pendingRequests}</span> <span>{copy().pendingRequests}</span>
@@ -3232,6 +3503,12 @@ function App() {
</article> </article>
)} )}
<ShowDashboard
dashboard={dashboard()}
fallback={null}
render={(data) => renderFinanceVisuals(data)}
/>
<article class="balance-item balance-item--wide"> <article class="balance-item balance-item--wide">
<header> <header>
<strong>{copy().latestActivityTitle}</strong> <strong>{copy().latestActivityTitle}</strong>

View File

@@ -57,6 +57,16 @@ export const dictionary = {
finalDue: 'Final due', finalDue: 'Final due',
householdBalancesTitle: 'Household balances', householdBalancesTitle: 'Household balances',
householdBalancesBody: 'Everyones current split for this cycle.', householdBalancesBody: 'Everyones 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', purchasesTitle: 'Shared purchases',
purchasesEmpty: 'No shared purchases recorded for this cycle yet.', purchasesEmpty: 'No shared purchases recorded for this cycle yet.',
utilityLedgerTitle: 'Utility bills', utilityLedgerTitle: 'Utility bills',
@@ -242,6 +252,16 @@ export const dictionary = {
finalDue: 'Итог к оплате', finalDue: 'Итог к оплате',
householdBalancesTitle: 'Баланс household', householdBalancesTitle: 'Баланс household',
householdBalancesBody: 'Текущий расклад по всем участникам за этот цикл.', householdBalancesBody: 'Текущий расклад по всем участникам за этот цикл.',
financeVisualsTitle: 'Визуальный разбор баланса',
financeVisualsBody:
'Полосы показывают, как аренда, коммуналка и поправка на общие покупки формируют баланс каждого участника.',
purchaseInvestmentsTitle: 'Кто оплачивал общие покупки',
purchaseInvestmentsBody:
'Кольцевая диаграмма показывает, сколько каждый участник вложил в общие покупки в этом цикле.',
purchaseInvestmentsEmpty:
'Вклады в общие покупки появятся здесь после первых записанных покупок.',
purchaseShareLabel: 'Доля',
purchaseTotalLabel: 'Всего общих покупок',
purchasesTitle: 'Общие покупки', purchasesTitle: 'Общие покупки',
purchasesEmpty: 'Пока нет общих покупок в этом цикле.', purchasesEmpty: 'Пока нет общих покупок в этом цикле.',
utilityLedgerTitle: 'Коммунальные платежи', utilityLedgerTitle: 'Коммунальные платежи',

View File

@@ -308,6 +308,170 @@ button {
margin-top: 12px; 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 { .stat-card span {
color: #c6c2bb; color: #c6c2bb;
font-size: 0.82rem; font-size: 0.82rem;
@@ -503,6 +667,15 @@ button {
grid-template-columns: repeat(3, minmax(0, 1fr)); 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 { .admin-summary-grid {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
@@ -560,6 +733,10 @@ button {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.member-visual-card header {
grid-template-columns: minmax(0, 1fr);
}
.admin-section__header { .admin-section__header {
align-items: start; align-items: start;
} }