mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
feat(miniapp): clarify balance breakdown
This commit is contained in:
@@ -47,6 +47,7 @@ type SessionState =
|
|||||||
status: 'ready'
|
status: 'ready'
|
||||||
mode: 'live' | 'demo'
|
mode: 'live' | 'demo'
|
||||||
member: {
|
member: {
|
||||||
|
id: string
|
||||||
displayName: string
|
displayName: string
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
preferredLocale: Locale | null
|
preferredLocale: Locale | null
|
||||||
@@ -65,6 +66,7 @@ const demoSession: Extract<SessionState, { status: 'ready' }> = {
|
|||||||
status: 'ready',
|
status: 'ready',
|
||||||
mode: 'demo',
|
mode: 'demo',
|
||||||
member: {
|
member: {
|
||||||
|
id: 'demo-member',
|
||||||
displayName: 'Demo Resident',
|
displayName: 'Demo Resident',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
preferredLocale: 'en',
|
preferredLocale: 'en',
|
||||||
@@ -131,6 +133,33 @@ function defaultCyclePeriod(): string {
|
|||||||
return new Date().toISOString().slice(0, 7)
|
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() {
|
function App() {
|
||||||
const [locale, setLocale] = createSignal<Locale>('en')
|
const [locale, setLocale] = createSignal<Locale>('en')
|
||||||
const [session, setSession] = createSignal<SessionState>({
|
const [session, setSession] = createSignal<SessionState>({
|
||||||
@@ -185,6 +214,22 @@ function App() {
|
|||||||
const current = session()
|
const current = session()
|
||||||
return current.status === 'ready' ? current : null
|
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()
|
const webApp = getTelegramWebApp()
|
||||||
|
|
||||||
async function loadDashboard(initData: string) {
|
async function loadDashboard(initData: string) {
|
||||||
@@ -341,24 +386,24 @@ function App() {
|
|||||||
setDashboard({
|
setDashboard({
|
||||||
period: '2026-03',
|
period: '2026-03',
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
totalDueMajor: '820.00',
|
totalDueMajor: '414.00',
|
||||||
members: [
|
members: [
|
||||||
{
|
{
|
||||||
memberId: 'alice',
|
memberId: 'demo-member',
|
||||||
displayName: 'Alice',
|
displayName: 'Demo Resident',
|
||||||
rentShareMajor: '350.00',
|
rentShareMajor: '175.00',
|
||||||
utilityShareMajor: '60.00',
|
utilityShareMajor: '32.00',
|
||||||
purchaseOffsetMajor: '-15.00',
|
purchaseOffsetMajor: '-14.00',
|
||||||
netDueMajor: '395.00',
|
netDueMajor: '193.00',
|
||||||
explanations: ['Equal utility split', 'Shared purchase offset']
|
explanations: ['Equal utility split', 'Shared purchase offset']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
memberId: 'bob',
|
memberId: 'member-2',
|
||||||
displayName: 'Bob',
|
displayName: 'Alice',
|
||||||
rentShareMajor: '350.00',
|
rentShareMajor: '175.00',
|
||||||
utilityShareMajor: '60.00',
|
utilityShareMajor: '32.00',
|
||||||
purchaseOffsetMajor: '15.00',
|
purchaseOffsetMajor: '14.00',
|
||||||
netDueMajor: '425.00',
|
netDueMajor: '221.00',
|
||||||
explanations: ['Equal utility split']
|
explanations: ['Equal utility split']
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -781,27 +826,69 @@ function App() {
|
|||||||
<ShowDashboard
|
<ShowDashboard
|
||||||
dashboard={dashboard()}
|
dashboard={dashboard()}
|
||||||
fallback={<p>{copy().emptyDashboard}</p>}
|
fallback={<p>{copy().emptyDashboard}</p>}
|
||||||
render={(data) =>
|
render={(data) => (
|
||||||
data.members.map((member) => (
|
<>
|
||||||
|
{currentMemberLine() ? (
|
||||||
|
<article class="balance-item balance-item--accent">
|
||||||
|
<header>
|
||||||
|
<strong>{copy().yourBalanceTitle}</strong>
|
||||||
|
<span>
|
||||||
|
{currentMemberLine()!.netDueMajor} {data.currency}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<p>{copy().yourBalanceBody}</p>
|
||||||
|
<div class="balance-breakdown">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>{copy().baseDue}</span>
|
||||||
|
<strong>
|
||||||
|
{memberBaseDueMajor(currentMemberLine()!)} {data.currency}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>{copy().shareOffset}</span>
|
||||||
|
<strong>
|
||||||
|
{currentMemberLine()!.purchaseOffsetMajor} {data.currency}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>{copy().finalDue}</span>
|
||||||
|
<strong>
|
||||||
|
{currentMemberLine()!.netDueMajor} {data.currency}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
) : null}
|
||||||
<article class="balance-item">
|
<article class="balance-item">
|
||||||
<header>
|
<header>
|
||||||
<strong>{member.displayName}</strong>
|
<strong>{copy().householdBalancesTitle}</strong>
|
||||||
<span>
|
|
||||||
{member.netDueMajor} {data.currency}
|
|
||||||
</span>
|
|
||||||
</header>
|
</header>
|
||||||
<p>
|
<p>{copy().householdBalancesBody}</p>
|
||||||
{copy().shareRent}: {member.rentShareMajor} {data.currency}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{copy().shareUtilities}: {member.utilityShareMajor} {data.currency}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{copy().shareOffset}: {member.purchaseOffsetMajor} {data.currency}
|
|
||||||
</p>
|
|
||||||
</article>
|
</article>
|
||||||
))
|
{data.members.map((member) => (
|
||||||
}
|
<article class="balance-item">
|
||||||
|
<header>
|
||||||
|
<strong>{member.displayName}</strong>
|
||||||
|
<span>
|
||||||
|
{member.netDueMajor} {data.currency}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<p>
|
||||||
|
{copy().baseDue}: {memberBaseDueMajor(member)} {data.currency}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{copy().shareRent}: {member.rentShareMajor} {data.currency}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{copy().shareUtilities}: {member.utilityShareMajor} {data.currency}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{copy().shareOffset}: {member.purchaseOffsetMajor} {data.currency}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -811,19 +898,54 @@ function App() {
|
|||||||
<ShowDashboard
|
<ShowDashboard
|
||||||
dashboard={dashboard()}
|
dashboard={dashboard()}
|
||||||
fallback={<p>{copy().emptyDashboard}</p>}
|
fallback={<p>{copy().emptyDashboard}</p>}
|
||||||
render={(data) =>
|
render={(data) => (
|
||||||
data.ledger.map((entry) => (
|
<>
|
||||||
<article class="ledger-item">
|
<article class="balance-item">
|
||||||
<header>
|
<header>
|
||||||
<strong>{entry.title}</strong>
|
<strong>{copy().purchasesTitle}</strong>
|
||||||
<span>
|
|
||||||
{entry.amountMajor} {data.currency}
|
|
||||||
</span>
|
|
||||||
</header>
|
</header>
|
||||||
<p>{entry.actorDisplayName ?? 'Household'}</p>
|
{purchaseLedger().length === 0 ? (
|
||||||
|
<p>{copy().purchasesEmpty}</p>
|
||||||
|
) : (
|
||||||
|
<div class="ledger-list">
|
||||||
|
{purchaseLedger().map((entry) => (
|
||||||
|
<article class="ledger-item">
|
||||||
|
<header>
|
||||||
|
<strong>{entry.title}</strong>
|
||||||
|
<span>
|
||||||
|
{entry.amountMajor} {data.currency}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<p>{entry.actorDisplayName ?? copy().ledgerActorFallback}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
))
|
<article class="balance-item">
|
||||||
}
|
<header>
|
||||||
|
<strong>{copy().utilityLedgerTitle}</strong>
|
||||||
|
</header>
|
||||||
|
{utilityLedger().length === 0 ? (
|
||||||
|
<p>{copy().utilityLedgerEmpty}</p>
|
||||||
|
) : (
|
||||||
|
<div class="ledger-list">
|
||||||
|
{utilityLedger().map((entry) => (
|
||||||
|
<article class="ledger-item">
|
||||||
|
<header>
|
||||||
|
<strong>{entry.title}</strong>
|
||||||
|
<span>
|
||||||
|
{entry.amountMajor} {data.currency}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<p>{entry.actorDisplayName ?? copy().ledgerActorFallback}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1375,6 +1497,10 @@ function App() {
|
|||||||
<span>{copy().ledgerEntries}</span>
|
<span>{copy().ledgerEntries}</span>
|
||||||
<strong>{dashboardLedgerCount(dashboard())}</strong>
|
<strong>{dashboardLedgerCount(dashboard())}</strong>
|
||||||
</article>
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<span>{copy().purchasesTitle}</span>
|
||||||
|
<strong>{String(purchaseLedger().length)}</strong>
|
||||||
|
</article>
|
||||||
{readySession()?.member.isAdmin ? (
|
{readySession()?.member.isAdmin ? (
|
||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<span>{copy().pendingRequests}</span>
|
<span>{copy().pendingRequests}</span>
|
||||||
@@ -1382,12 +1508,44 @@ function App() {
|
|||||||
</article>
|
</article>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<article class="balance-item">
|
{currentMemberLine() ? (
|
||||||
<header>
|
<article class="balance-item balance-item--accent">
|
||||||
<strong>{copy().overviewTitle}</strong>
|
<header>
|
||||||
</header>
|
<strong>{copy().yourBalanceTitle}</strong>
|
||||||
<p>{copy().overviewBody}</p>
|
<span>
|
||||||
</article>
|
{currentMemberLine()!.netDueMajor} {dashboard()?.currency ?? ''}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<p>{copy().yourBalanceBody}</p>
|
||||||
|
<div class="balance-breakdown">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>{copy().baseDue}</span>
|
||||||
|
<strong>
|
||||||
|
{memberBaseDueMajor(currentMemberLine()!)} {dashboard()?.currency ?? ''}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>{copy().shareOffset}</span>
|
||||||
|
<strong>
|
||||||
|
{currentMemberLine()!.purchaseOffsetMajor} {dashboard()?.currency ?? ''}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>{copy().finalDue}</span>
|
||||||
|
<strong>
|
||||||
|
{currentMemberLine()!.netDueMajor} {dashboard()?.currency ?? ''}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
) : (
|
||||||
|
<article class="balance-item">
|
||||||
|
<header>
|
||||||
|
<strong>{copy().overviewTitle}</strong>
|
||||||
|
</header>
|
||||||
|
<p>{copy().overviewBody}</p>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
|
||||||
<article class="balance-item">
|
<article class="balance-item">
|
||||||
<header>
|
<header>
|
||||||
@@ -1409,7 +1567,7 @@ function App() {
|
|||||||
{entry.amountMajor} {data.currency}
|
{entry.amountMajor} {data.currency}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<p>{entry.actorDisplayName ?? 'Household'}</p>
|
<p>{entry.actorDisplayName ?? copy().ledgerActorFallback}</p>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,6 +45,17 @@ export const dictionary = {
|
|||||||
membersCount: 'Members',
|
membersCount: 'Members',
|
||||||
ledgerEntries: 'Ledger entries',
|
ledgerEntries: 'Ledger entries',
|
||||||
pendingRequests: 'Pending requests',
|
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',
|
shareRent: 'Rent',
|
||||||
shareUtilities: 'Utilities',
|
shareUtilities: 'Utilities',
|
||||||
shareOffset: 'Shared buys',
|
shareOffset: 'Shared buys',
|
||||||
@@ -150,6 +161,17 @@ export const dictionary = {
|
|||||||
membersCount: 'Участники',
|
membersCount: 'Участники',
|
||||||
ledgerEntries: 'Записи леджера',
|
ledgerEntries: 'Записи леджера',
|
||||||
pendingRequests: 'Ожидают подтверждения',
|
pendingRequests: 'Ожидают подтверждения',
|
||||||
|
yourBalanceTitle: 'Твой баланс',
|
||||||
|
yourBalanceBody: 'Посмотри свой баланс за текущий цикл до и после поправки на общие покупки.',
|
||||||
|
baseDue: 'База к оплате',
|
||||||
|
finalDue: 'Итог к оплате',
|
||||||
|
householdBalancesTitle: 'Баланс household',
|
||||||
|
householdBalancesBody: 'Текущий расклад по всем участникам за этот цикл.',
|
||||||
|
purchasesTitle: 'Общие покупки',
|
||||||
|
purchasesEmpty: 'Пока нет общих покупок в этом цикле.',
|
||||||
|
utilityLedgerTitle: 'Коммунальные платежи',
|
||||||
|
utilityLedgerEmpty: 'Пока нет коммунальных платежей в этом цикле.',
|
||||||
|
ledgerActorFallback: 'Household',
|
||||||
shareRent: 'Аренда',
|
shareRent: 'Аренда',
|
||||||
shareUtilities: 'Коммуналка',
|
shareUtilities: 'Коммуналка',
|
||||||
shareOffset: 'Общие покупки',
|
shareOffset: 'Общие покупки',
|
||||||
|
|||||||
@@ -243,6 +243,13 @@ button {
|
|||||||
background: rgb(255 255 255 / 0.03);
|
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,
|
.balance-item header,
|
||||||
.ledger-item header {
|
.ledger-item header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -271,6 +278,12 @@ button {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.balance-breakdown {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-card span {
|
.stat-card span {
|
||||||
color: #c6c2bb;
|
color: #c6c2bb;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
@@ -344,6 +357,10 @@ button {
|
|||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.balance-breakdown {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.settings-grid {
|
.settings-grid {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { runtimeBotApiUrl } from './runtime-config'
|
|||||||
export interface MiniAppSession {
|
export interface MiniAppSession {
|
||||||
authorized: boolean
|
authorized: boolean
|
||||||
member?: {
|
member?: {
|
||||||
|
id: string
|
||||||
|
householdId: string
|
||||||
displayName: string
|
displayName: string
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
preferredLocale: 'en' | 'ru' | null
|
preferredLocale: 'en' | 'ru' | null
|
||||||
|
|||||||
19
docs/specs/HOUSEBOT-078-miniapp-balance-breakdown.md
Normal file
19
docs/specs/HOUSEBOT-078-miniapp-balance-breakdown.md
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user