From d55bf42c7bd4b9e07573779a32114eaf9e560e44 Mon Sep 17 00:00:00 2001 From: whekin Date: Tue, 10 Mar 2026 02:54:12 +0400 Subject: [PATCH] feat(miniapp): clarify balance breakdown --- apps/miniapp/src/App.tsx | 252 ++++++++++++++---- apps/miniapp/src/i18n.ts | 22 ++ apps/miniapp/src/index.css | 17 ++ apps/miniapp/src/miniapp-api.ts | 2 + .../HOUSEBOT-078-miniapp-balance-breakdown.md | 19 ++ 5 files changed, 265 insertions(+), 47 deletions(-) create mode 100644 docs/specs/HOUSEBOT-078-miniapp-balance-breakdown.md diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index 807bd78..9de0c93 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -47,6 +47,7 @@ type SessionState = status: 'ready' mode: 'live' | 'demo' member: { + id: string displayName: string isAdmin: boolean preferredLocale: Locale | null @@ -65,6 +66,7 @@ const demoSession: Extract = { status: 'ready', mode: 'demo', member: { + id: 'demo-member', displayName: 'Demo Resident', isAdmin: false, preferredLocale: 'en', @@ -131,6 +133,33 @@ function defaultCyclePeriod(): string { return new Date().toISOString().slice(0, 7) } +function majorStringToMinor(value: string): bigint { + const trimmed = value.trim() + const negative = trimmed.startsWith('-') + const normalized = negative ? trimmed.slice(1) : trimmed + const [whole = '0', fraction = ''] = normalized.split('.') + const major = BigInt(whole || '0') + const cents = BigInt((fraction.padEnd(2, '0').slice(0, 2) || '00').replace(/\D/g, '') || '0') + const minor = major * 100n + cents + + return negative ? -minor : minor +} + +function minorToMajorString(value: bigint): string { + const negative = value < 0n + const absolute = negative ? -value : value + const whole = absolute / 100n + const fraction = String(absolute % 100n).padStart(2, '0') + + return `${negative ? '-' : ''}${whole.toString()}.${fraction}` +} + +function memberBaseDueMajor(member: MiniAppDashboard['members'][number]): string { + return minorToMajorString( + majorStringToMinor(member.rentShareMajor) + majorStringToMinor(member.utilityShareMajor) + ) +} + function App() { const [locale, setLocale] = createSignal('en') const [session, setSession] = createSignal({ @@ -185,6 +214,22 @@ function App() { const current = session() return current.status === 'ready' ? current : null }) + const currentMemberLine = createMemo(() => { + const current = readySession() + const data = dashboard() + + if (!current || !data) { + return null + } + + return data.members.find((member) => member.memberId === current.member.id) ?? null + }) + const purchaseLedger = createMemo(() => + (dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'purchase') + ) + const utilityLedger = createMemo(() => + (dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'utility') + ) const webApp = getTelegramWebApp() async function loadDashboard(initData: string) { @@ -341,24 +386,24 @@ function App() { setDashboard({ period: '2026-03', currency: 'USD', - totalDueMajor: '820.00', + totalDueMajor: '414.00', members: [ { - memberId: 'alice', - displayName: 'Alice', - rentShareMajor: '350.00', - utilityShareMajor: '60.00', - purchaseOffsetMajor: '-15.00', - netDueMajor: '395.00', + memberId: 'demo-member', + displayName: 'Demo Resident', + rentShareMajor: '175.00', + utilityShareMajor: '32.00', + purchaseOffsetMajor: '-14.00', + netDueMajor: '193.00', explanations: ['Equal utility split', 'Shared purchase offset'] }, { - memberId: 'bob', - displayName: 'Bob', - rentShareMajor: '350.00', - utilityShareMajor: '60.00', - purchaseOffsetMajor: '15.00', - netDueMajor: '425.00', + memberId: 'member-2', + displayName: 'Alice', + rentShareMajor: '175.00', + utilityShareMajor: '32.00', + purchaseOffsetMajor: '14.00', + netDueMajor: '221.00', explanations: ['Equal utility split'] } ], @@ -781,27 +826,69 @@ function App() { {copy().emptyDashboard}

} - render={(data) => - data.members.map((member) => ( + render={(data) => ( + <> + {currentMemberLine() ? ( +
+
+ {copy().yourBalanceTitle} + + {currentMemberLine()!.netDueMajor} {data.currency} + +
+

{copy().yourBalanceBody}

+
+
+ {copy().baseDue} + + {memberBaseDueMajor(currentMemberLine()!)} {data.currency} + +
+
+ {copy().shareOffset} + + {currentMemberLine()!.purchaseOffsetMajor} {data.currency} + +
+
+ {copy().finalDue} + + {currentMemberLine()!.netDueMajor} {data.currency} + +
+
+
+ ) : null}
- {member.displayName} - - {member.netDueMajor} {data.currency} - + {copy().householdBalancesTitle}
-

- {copy().shareRent}: {member.rentShareMajor} {data.currency} -

-

- {copy().shareUtilities}: {member.utilityShareMajor} {data.currency} -

-

- {copy().shareOffset}: {member.purchaseOffsetMajor} {data.currency} -

+

{copy().householdBalancesBody}

- )) - } + {data.members.map((member) => ( +
+
+ {member.displayName} + + {member.netDueMajor} {data.currency} + +
+

+ {copy().baseDue}: {memberBaseDueMajor(member)} {data.currency} +

+

+ {copy().shareRent}: {member.rentShareMajor} {data.currency} +

+

+ {copy().shareUtilities}: {member.utilityShareMajor} {data.currency} +

+

+ {copy().shareOffset}: {member.purchaseOffsetMajor} {data.currency} +

+
+ ))} + + )} /> ) @@ -811,19 +898,54 @@ function App() { {copy().emptyDashboard}

} - render={(data) => - data.ledger.map((entry) => ( -
+ render={(data) => ( + <> +
- {entry.title} - - {entry.amountMajor} {data.currency} - + {copy().purchasesTitle}
-

{entry.actorDisplayName ?? 'Household'}

+ {purchaseLedger().length === 0 ? ( +

{copy().purchasesEmpty}

+ ) : ( +
+ {purchaseLedger().map((entry) => ( +
+
+ {entry.title} + + {entry.amountMajor} {data.currency} + +
+

{entry.actorDisplayName ?? copy().ledgerActorFallback}

+
+ ))} +
+ )}
- )) - } +
+
+ {copy().utilityLedgerTitle} +
+ {utilityLedger().length === 0 ? ( +

{copy().utilityLedgerEmpty}

+ ) : ( +
+ {utilityLedger().map((entry) => ( +
+
+ {entry.title} + + {entry.amountMajor} {data.currency} + +
+

{entry.actorDisplayName ?? copy().ledgerActorFallback}

+
+ ))} +
+ )} +
+ + )} /> ) @@ -1375,6 +1497,10 @@ function App() { {copy().ledgerEntries} {dashboardLedgerCount(dashboard())}
+
+ {copy().purchasesTitle} + {String(purchaseLedger().length)} +
{readySession()?.member.isAdmin ? (
{copy().pendingRequests} @@ -1382,12 +1508,44 @@ function App() {
) : null} -
-
- {copy().overviewTitle} -
-

{copy().overviewBody}

-
+ {currentMemberLine() ? ( +
+
+ {copy().yourBalanceTitle} + + {currentMemberLine()!.netDueMajor} {dashboard()?.currency ?? ''} + +
+

{copy().yourBalanceBody}

+
+
+ {copy().baseDue} + + {memberBaseDueMajor(currentMemberLine()!)} {dashboard()?.currency ?? ''} + +
+
+ {copy().shareOffset} + + {currentMemberLine()!.purchaseOffsetMajor} {dashboard()?.currency ?? ''} + +
+
+ {copy().finalDue} + + {currentMemberLine()!.netDueMajor} {dashboard()?.currency ?? ''} + +
+
+
+ ) : ( +
+
+ {copy().overviewTitle} +
+

{copy().overviewBody}

+
+ )}
@@ -1409,7 +1567,7 @@ function App() { {entry.amountMajor} {data.currency}
-

{entry.actorDisplayName ?? 'Household'}

+

{entry.actorDisplayName ?? copy().ledgerActorFallback}

))} diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index 8833fc9..7e2c84c 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -45,6 +45,17 @@ export const dictionary = { membersCount: 'Members', ledgerEntries: 'Ledger entries', pendingRequests: 'Pending requests', + yourBalanceTitle: 'Your balance', + yourBalanceBody: 'See your current cycle balance before and after shared household purchases.', + baseDue: 'Base due', + finalDue: 'Final due', + householdBalancesTitle: 'Household balances', + householdBalancesBody: 'Everyone’s current split for this cycle.', + purchasesTitle: 'Shared purchases', + purchasesEmpty: 'No shared purchases recorded for this cycle yet.', + utilityLedgerTitle: 'Utility bills', + utilityLedgerEmpty: 'No utility bills recorded for this cycle yet.', + ledgerActorFallback: 'Household', shareRent: 'Rent', shareUtilities: 'Utilities', shareOffset: 'Shared buys', @@ -150,6 +161,17 @@ export const dictionary = { membersCount: 'Участники', ledgerEntries: 'Записи леджера', pendingRequests: 'Ожидают подтверждения', + yourBalanceTitle: 'Твой баланс', + yourBalanceBody: 'Посмотри свой баланс за текущий цикл до и после поправки на общие покупки.', + baseDue: 'База к оплате', + finalDue: 'Итог к оплате', + householdBalancesTitle: 'Баланс household', + householdBalancesBody: 'Текущий расклад по всем участникам за этот цикл.', + purchasesTitle: 'Общие покупки', + purchasesEmpty: 'Пока нет общих покупок в этом цикле.', + utilityLedgerTitle: 'Коммунальные платежи', + utilityLedgerEmpty: 'Пока нет коммунальных платежей в этом цикле.', + ledgerActorFallback: 'Household', shareRent: 'Аренда', shareUtilities: 'Коммуналка', shareOffset: 'Общие покупки', diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index 0759bdb..e29ed1e 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -243,6 +243,13 @@ button { background: rgb(255 255 255 / 0.03); } +.balance-item--accent { + background: + linear-gradient(180deg, rgb(247 179 137 / 0.12), rgb(255 255 255 / 0.03)), + rgb(255 255 255 / 0.03); + border-color: rgb(247 179 137 / 0.28); +} + .balance-item header, .ledger-item header { display: flex; @@ -271,6 +278,12 @@ button { gap: 8px; } +.balance-breakdown { + display: grid; + gap: 10px; + margin-top: 12px; +} + .stat-card span { color: #c6c2bb; font-size: 0.82rem; @@ -344,6 +357,10 @@ button { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .balance-breakdown { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .settings-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index f367688..59d3737 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -3,6 +3,8 @@ import { runtimeBotApiUrl } from './runtime-config' export interface MiniAppSession { authorized: boolean member?: { + id: string + householdId: string displayName: string isAdmin: boolean preferredLocale: 'en' | 'ru' | null diff --git a/docs/specs/HOUSEBOT-078-miniapp-balance-breakdown.md b/docs/specs/HOUSEBOT-078-miniapp-balance-breakdown.md new file mode 100644 index 0000000..bfa1ae0 --- /dev/null +++ b/docs/specs/HOUSEBOT-078-miniapp-balance-breakdown.md @@ -0,0 +1,19 @@ +# HOUSEBOT-078 Mini App Balance Breakdown + +## Goal + +Make the mini app read like a real household statement instead of a generic dashboard shell. + +## Scope + +- highlight the current member's own balance first +- show base due (`rent + utilities`) separately from the shared-purchase adjustment and final due +- keep full-household balance visibility below the personal summary +- split ledger presentation into shared purchases and utility bills +- avoid float math in UI money calculations + +## Notes + +- no settlement logic changes in this slice +- use existing dashboard API data where possible +- prefer exact bigint formatting helpers over `number` math in the client