fix(miniapp): tighten house layout and restore balance details

This commit is contained in:
2026-03-12 12:45:00 +04:00
parent ae2a54a7bf
commit a38686c8b0
6 changed files with 319 additions and 37 deletions

View File

@@ -106,6 +106,8 @@ type SessionState =
type NavigationKey = 'home' | 'balances' | 'ledger' | 'house' type NavigationKey = 'home' | 'balances' | 'ledger' | 'house'
const chartPalette = ['#f7b389', '#6fd3c0', '#f06a8d', '#94a8ff', '#f3d36f', '#7dc96d'] as const
type UtilityBillDraft = { type UtilityBillDraft = {
billName: string billName: string
amountMajor: string amountMajor: string
@@ -187,6 +189,30 @@ function defaultCyclePeriod(): string {
return new Date().toISOString().slice(0, 7) return new Date().toISOString().slice(0, 7)
} }
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)
)
}
function memberRemainingClass(member: MiniAppDashboard['members'][number]): string {
const remainingMinor = majorStringToMinor(member.remainingMajor)
if (remainingMinor < 0n) {
return 'is-credit'
}
if (remainingMinor === 0n) {
return 'is-settled'
}
return 'is-due'
}
function ledgerPrimaryAmount(entry: MiniAppDashboard['ledger'][number]): string { function ledgerPrimaryAmount(entry: MiniAppDashboard['ledger'][number]): string {
return `${entry.displayAmountMajor} ${entry.displayCurrency}` return `${entry.displayAmountMajor} ${entry.displayCurrency}`
} }
@@ -458,6 +484,134 @@ function App() {
) )
) )
) )
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 {
@@ -1870,6 +2024,12 @@ function App() {
locale={locale()} locale={locale()}
dashboard={dashboard()} dashboard={dashboard()}
currentMemberLine={currentMemberLine()} currentMemberLine={currentMemberLine()}
utilityTotalMajor={utilityTotalMajor()}
purchaseTotalMajor={purchaseTotalMajor()}
memberBalanceVisuals={memberBalanceVisuals()}
purchaseChart={purchaseInvestmentChart()}
memberBaseDueMajor={memberBaseDueMajor}
memberRemainingClass={memberRemainingClass}
/> />
) )
case 'ledger': case 'ledger':

View File

@@ -30,8 +30,7 @@ export const dictionary = {
reload: 'Retry', reload: 'Retry',
language: 'Language', language: 'Language',
householdLanguage: 'Household language', householdLanguage: 'Household language',
generalSettingsBody: generalSettingsBody: 'Household name, default language, and your profile controls live here.',
'Household identity, default language, and personal profile controls live here.',
householdNameLabel: 'Household name', householdNameLabel: 'Household name',
householdNameHint: 'This appears in the mini app, join flow, and bot responses.', householdNameHint: 'This appears in the mini app, join flow, and bot responses.',
savingLanguage: 'Saving…', savingLanguage: 'Saving…',
@@ -237,6 +236,8 @@ export const dictionary = {
editCategoryAction: 'Edit category', editCategoryAction: 'Edit category',
adminsTitle: 'Admins', adminsTitle: 'Admins',
adminsBody: 'Promote trusted household members so they can manage billing and approvals.', adminsBody: 'Promote trusted household members so they can manage billing and approvals.',
membersTitle: 'Members',
membersBody: 'Review roles, billing weights, and status for everyone in the household.',
displayNameLabel: 'Household display name', displayNameLabel: 'Household display name',
displayNameHint: 'This name appears in balances, ledger entries, and assistant replies.', displayNameHint: 'This name appears in balances, ledger entries, and assistant replies.',
manageProfileAction: 'Edit profile', manageProfileAction: 'Edit profile',
@@ -310,7 +311,7 @@ export const dictionary = {
reload: 'Повторить', reload: 'Повторить',
language: 'Язык', language: 'Язык',
householdLanguage: 'Язык дома', householdLanguage: 'Язык дома',
generalSettingsBody: 'Здесь живут имя дома, язык по умолчанию и доступ к личному профилю.', generalSettingsBody: 'Здесь настраиваются название дома, язык по умолчанию и твой профиль.',
householdNameLabel: 'Название дома', householdNameLabel: 'Название дома',
householdNameHint: 'Показывается в приложении, при вступлении и в ответах бота.', householdNameHint: 'Показывается в приложении, при вступлении и в ответах бота.',
savingLanguage: 'Сохраняем…', savingLanguage: 'Сохраняем…',
@@ -518,6 +519,8 @@ export const dictionary = {
adminsTitle: 'Админы', adminsTitle: 'Админы',
adminsBody: adminsBody:
'Повышай доверенных участников, чтобы они могли управлять биллингом и подтверждениями.', 'Повышай доверенных участников, чтобы они могли управлять биллингом и подтверждениями.',
membersTitle: 'Участники',
membersBody: 'Здесь собраны роли, веса аренды и статусы всех участников дома.',
displayNameLabel: 'Имя в доме', displayNameLabel: 'Имя в доме',
displayNameHint: 'Это имя будет видно в балансе, леджере и ответах ассистента.', displayNameHint: 'Это имя будет видно в балансе, леджере и ответах ассистента.',
manageProfileAction: 'Редактировать профиль', manageProfileAction: 'Редактировать профиль',

View File

@@ -34,6 +34,11 @@ body {
button { button {
font: inherit; font: inherit;
cursor: pointer;
}
button:disabled {
cursor: default;
} }
#root { #root {
@@ -44,7 +49,7 @@ button {
position: relative; position: relative;
min-height: 100vh; min-height: 100vh;
overflow: hidden; overflow: hidden;
padding: 24px 18px 108px; padding: 24px 18px calc(140px + env(safe-area-inset-bottom));
} }
.shell__backdrop { .shell__backdrop {
@@ -343,7 +348,7 @@ button {
.app-bottom-nav { .app-bottom-nav {
position: fixed; position: fixed;
right: 18px; right: 18px;
bottom: 20px; bottom: calc(12px + env(safe-area-inset-bottom));
left: 18px; left: 18px;
z-index: 3; z-index: 3;
} }
@@ -358,6 +363,7 @@ button {
display: grid; display: grid;
gap: 12px; gap: 12px;
margin-top: 12px; margin-top: 12px;
padding-bottom: 12px;
} }
.summary-card-grid { .summary-card-grid {
@@ -387,6 +393,7 @@ button {
.activity-row, .activity-row,
.utility-bill-row { .utility-bill-row {
display: grid; display: grid;
align-content: start;
gap: 10px; gap: 10px;
border: 1px solid rgb(255 255 255 / 0.08); border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 18px; border-radius: 18px;
@@ -769,25 +776,23 @@ button {
.admin-grid { .admin-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
align-items: start;
gap: 12px; gap: 12px;
} }
.admin-section { .admin-section {
display: grid; display: grid;
align-items: start;
gap: 12px; gap: 12px;
} }
.admin-disclosure { .admin-disclosure {
border: 1px solid rgb(255 255 255 / 0.08); border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 20px; border-radius: 20px;
background: rgb(255 255 255 / 0.03);
overflow: hidden;
}
.admin-disclosure[open] {
background: background:
linear-gradient(180deg, rgb(255 255 255 / 0.05), rgb(255 255 255 / 0.02)), linear-gradient(180deg, rgb(255 255 255 / 0.05), rgb(255 255 255 / 0.02)),
rgb(255 255 255 / 0.03); rgb(255 255 255 / 0.03);
overflow: hidden;
} }
.admin-disclosure__summary { .admin-disclosure__summary {
@@ -795,13 +800,11 @@ button {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
width: 100%;
border: 0;
background: transparent;
padding: 16px; padding: 16px;
cursor: pointer; text-align: left;
list-style: none;
}
.admin-disclosure__summary::-webkit-details-marker {
display: none;
} }
.admin-disclosure__copy { .admin-disclosure__copy {
@@ -813,7 +816,7 @@ button {
transition: transform 140ms ease; transition: transform 140ms ease;
} }
.admin-disclosure[open] .admin-disclosure__icon { .admin-disclosure__icon.is-open {
transform: rotate(180deg); transform: rotate(180deg);
} }
@@ -966,7 +969,7 @@ button {
-webkit-appearance: none; -webkit-appearance: none;
border: 0; border: 0;
background: transparent; background: transparent;
padding: 0; padding: 6px 10px;
font: inherit; font: inherit;
line-height: inherit; line-height: inherit;
cursor: pointer; cursor: pointer;
@@ -979,6 +982,7 @@ button {
} }
.timezone-chip { .timezone-chip {
padding: 6px 10px;
border: 1px solid transparent; border: 1px solid transparent;
} }
@@ -1194,7 +1198,7 @@ button {
.shell { .shell {
max-width: 920px; max-width: 920px;
margin: 0 auto; margin: 0 auto;
padding: 32px 24px 40px; padding: 32px 24px 136px;
} }
.summary-card-grid { .summary-card-grid {
@@ -1266,7 +1270,7 @@ button {
@media (max-width: 759px) { @media (max-width: 759px) {
.shell { .shell {
padding: 18px 14px 28px; padding: 18px 14px calc(132px + env(safe-area-inset-bottom));
} }
.topbar { .topbar {

View File

@@ -1,5 +1,7 @@
import { Show } from 'solid-js' import { For, Show } from 'solid-js'
import { FinanceSummaryCards } from '../components/finance/finance-summary-cards'
import { FinanceVisuals } from '../components/finance/finance-visuals'
import { MemberBalanceCard } from '../components/finance/member-balance-card' import { MemberBalanceCard } from '../components/finance/member-balance-card'
import { formatCyclePeriod } from '../lib/dates' import { formatCyclePeriod } from '../lib/dates'
import type { MiniAppDashboard } from '../miniapp-api' import type { MiniAppDashboard } from '../miniapp-api'
@@ -9,6 +11,34 @@ type Props = {
locale: 'en' | 'ru' locale: 'en' | 'ru'
dashboard: MiniAppDashboard | null dashboard: MiniAppDashboard | null
currentMemberLine: MiniAppDashboard['members'][number] | null currentMemberLine: MiniAppDashboard['members'][number] | null
utilityTotalMajor: string
purchaseTotalMajor: string
memberBalanceVisuals: {
member: MiniAppDashboard['members'][number]
totalMinor: bigint
barWidthPercent: number
segments: {
key: string
label: string
amountMajor: string
amountMinor: bigint
widthPercent: number
}[]
}[]
purchaseChart: {
totalMajor: string
slices: {
key: string
label: string
amountMajor: string
color: string
percentage: number
dasharray: string
dashoffset: string
}[]
}
memberBaseDueMajor: (member: MiniAppDashboard['members'][number]) => string
memberRemainingClass: (member: MiniAppDashboard['members'][number]) => string
} }
export function BalancesScreen(props: Props) { export function BalancesScreen(props: Props) {
@@ -41,6 +71,82 @@ export function BalancesScreen(props: Props) {
</header> </header>
<p>{props.copy.balanceScreenScopeBody ?? ''}</p> <p>{props.copy.balanceScreenScopeBody ?? ''}</p>
</article> </article>
<article class="balance-item balance-item--wide balance-item--muted">
<header>
<strong>{props.copy.houseSnapshotTitle ?? ''}</strong>
<span>{formatCyclePeriod(dashboard().period, props.locale)}</span>
</header>
<p>{props.copy.houseSnapshotBody ?? ''}</p>
<div class="summary-card-grid summary-card-grid--secondary">
<FinanceSummaryCards
dashboard={dashboard()}
utilityTotalMajor={props.utilityTotalMajor}
purchaseTotalMajor={props.purchaseTotalMajor}
labels={{
remaining: props.copy.remainingLabel ?? '',
rent: props.copy.shareRent ?? '',
utilities: props.copy.shareUtilities ?? '',
purchases: props.copy.purchasesTitle ?? ''
}}
/>
</div>
</article>
<FinanceVisuals
dashboard={dashboard()}
memberVisuals={props.memberBalanceVisuals}
purchaseChart={props.purchaseChart}
remainingClass={props.memberRemainingClass}
labels={{
financeVisualsTitle: props.copy.financeVisualsTitle ?? '',
financeVisualsBody: props.copy.financeVisualsBody ?? '',
membersCount: props.copy.membersCount ?? '',
purchaseInvestmentsTitle: props.copy.purchaseInvestmentsTitle ?? '',
purchaseInvestmentsBody: props.copy.purchaseInvestmentsBody ?? '',
purchaseInvestmentsEmpty: props.copy.purchaseInvestmentsEmpty ?? '',
purchaseTotalLabel: props.copy.purchaseTotalLabel ?? '',
purchaseShareLabel: props.copy.purchaseShareLabel ?? ''
}}
/>
<article class="balance-item balance-item--wide">
<header>
<strong>{props.copy.householdBalancesTitle ?? ''}</strong>
<span>{String(dashboard().members.length)}</span>
</header>
<p>{props.copy.householdBalancesBody ?? ''}</p>
</article>
<For each={dashboard().members}>
{(member) => (
<article class="balance-item">
<header>
<strong>{member.displayName}</strong>
<span>
{member.remainingMajor} {dashboard().currency}
</span>
</header>
<p>
{props.copy.baseDue ?? ''}: {props.memberBaseDueMajor(member)}{' '}
{dashboard().currency}
</p>
<p>
{props.copy.shareRent ?? ''}: {member.rentShareMajor} {dashboard().currency}
</p>
<p>
{props.copy.shareUtilities ?? ''}: {member.utilityShareMajor}{' '}
{dashboard().currency}
</p>
<p>
{props.copy.shareOffset ?? ''}: {member.purchaseOffsetMajor}{' '}
{dashboard().currency}
</p>
<p>
{props.copy.paidLabel ?? ''}: {member.paidMajor} {dashboard().currency}
</p>
<p class={`balance-status ${props.memberRemainingClass(member)}`}>
{props.copy.remainingLabel ?? ''}: {member.remainingMajor} {dashboard().currency}
</p>
</article>
)}
</For>
</div> </div>
)} )}
</Show> </Show>

View File

@@ -1,4 +1,4 @@
import { For, Show, createMemo, type JSX } from 'solid-js' import { For, Show, createMemo, createSignal, type JSX } from 'solid-js'
import { import {
Button, Button,
@@ -177,17 +177,25 @@ function HouseSection(props: {
defaultOpen?: boolean | undefined defaultOpen?: boolean | undefined
children: JSX.Element children: JSX.Element
}) { }) {
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
return ( return (
<details class="admin-disclosure" open={props.defaultOpen}> <section class="admin-disclosure">
<summary class="admin-disclosure__summary"> <button
class="admin-disclosure__summary"
type="button"
onClick={() => setOpen((current) => !current)}
>
<div class="admin-disclosure__copy"> <div class="admin-disclosure__copy">
<strong>{props.title}</strong> <strong>{props.title}</strong>
<Show when={props.body}>{(body) => <p>{body()}</p>}</Show> <Show when={props.body}>{(body) => <p>{body()}</p>}</Show>
</div> </div>
<ChevronDownIcon class="admin-disclosure__icon" /> <ChevronDownIcon class={`admin-disclosure__icon${open() ? ' is-open' : ''}`} />
</summary> </button>
<Show when={open()}>
<div class="admin-disclosure__content">{props.children}</div> <div class="admin-disclosure__content">{props.children}</div>
</details> </Show>
</section>
) )
} }
@@ -1047,13 +1055,16 @@ export function HouseScreen(props: Props) {
</section> </section>
</HouseSection> </HouseSection>
<HouseSection title={props.copy.houseSectionMembers ?? ''} body={props.copy.adminsBody}> <HouseSection title={props.copy.houseSectionMembers ?? ''} body={props.copy.membersBody}>
<section class="admin-section"> <section class="admin-section">
<div class="admin-grid"> <div class="admin-grid">
<article class="balance-item admin-card--wide"> <article class="balance-item admin-card--wide">
<header> <header>
<strong>{props.copy.adminsTitle ?? ''}</strong> <strong>{props.copy.membersTitle ?? props.copy.houseSectionMembers ?? ''}</strong>
<span>{String(props.adminSettings?.members.length ?? 0)}</span> <span class="mini-chip mini-chip--muted">
{String(props.adminSettings?.members.length ?? 0)}{' '}
{props.copy.membersCount ?? ''}
</span>
</header> </header>
<div class="ledger-list"> <div class="ledger-list">
<For each={props.adminSettings?.members ?? []}> <For each={props.adminSettings?.members ?? []}>
@@ -1146,7 +1157,7 @@ export function HouseScreen(props: Props) {
</div> </div>
<Modal <Modal
open={Boolean(props.editingMember)} open={Boolean(props.editingMember)}
title={props.copy.adminsTitle ?? ''} title={props.copy.membersTitle ?? props.copy.houseSectionMembers ?? ''}
description={props.copy.memberEditorBody ?? ''} description={props.copy.memberEditorBody ?? ''}
closeLabel={props.copy.closeEditorAction ?? ''} closeLabel={props.copy.closeEditorAction ?? ''}
onClose={props.onCloseMemberEditor} onClose={props.onCloseMemberEditor}

View File

@@ -406,12 +406,10 @@ export function LedgerScreen(props: Props) {
<Show when={props.readyIsAdmin}> <Show when={props.readyIsAdmin}>
<p>{props.copy.paymentsAdminBody ?? ''}</p> <p>{props.copy.paymentsAdminBody ?? ''}</p>
<div class="panel-toolbar"> <div class="panel-toolbar">
<IconButton <Button variant="secondary" onClick={props.onOpenAddPayment}>
label={props.copy.paymentsAddAction ?? ''}
onClick={props.onOpenAddPayment}
>
<PlusIcon /> <PlusIcon />
</IconButton> {props.copy.paymentsAddAction ?? ''}
</Button>
</div> </div>
</Show> </Show>
{props.paymentEntries.length === 0 ? ( {props.paymentEntries.length === 0 ? (