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