fix(miniapp): clarify cycle summary and balance sections

This commit is contained in:
2026-03-12 13:12:39 +04:00
parent a38686c8b0
commit 6053379f31
15 changed files with 440 additions and 101 deletions

View File

@@ -1,7 +1,7 @@
import { Show } from 'solid-js'
import { cn } from '../../lib/cn'
import { formatFriendlyDate } from '../../lib/dates'
import { formatCyclePeriod, formatFriendlyDate } from '../../lib/dates'
import { majorStringToMinor, sumMajorStrings } from '../../lib/money'
import type { MiniAppDashboard } from '../../miniapp-api'
import { MiniChip, StatCard } from '../ui'
@@ -44,25 +44,25 @@ export function MemberBalanceCard(props: Props) {
<header class="balance-spotlight__header">
<div class="balance-spotlight__copy">
<strong>{props.copy.yourBalanceTitle ?? ''}</strong>
<p>{props.copy.yourBalanceBody ?? ''}</p>
<Show when={props.copy.yourBalanceBody}>{(body) => <p>{body()}</p>}</Show>
</div>
<div class="balance-spotlight__hero">
<span>{props.copy.remainingLabel ?? ''}</span>
<strong>
{props.member.remainingMajor} {props.dashboard.currency}
</strong>
<small>
{props.copy.totalDue ?? ''}: {props.member.netDueMajor} {props.dashboard.currency}
</small>
<Show when={majorStringToMinor(props.member.paidMajor) > 0n}>
<small>
{props.copy.totalDue ?? ''}: {props.member.netDueMajor} {props.dashboard.currency}
</small>
</Show>
</div>
</header>
<div class="balance-spotlight__stats">
<StatCard class="balance-spotlight__stat">
<span>{props.copy.totalDue ?? ''}</span>
<strong>
{props.member.netDueMajor} {props.dashboard.currency}
</strong>
<span>{props.copy.currentCycleLabel ?? ''}</span>
<strong>{formatCyclePeriod(props.dashboard.period, props.locale)}</strong>
</StatCard>
<StatCard class="balance-spotlight__stat">
<span>{props.copy.paidLabel ?? ''}</span>

View File

@@ -26,6 +26,9 @@ export const demoTelegramUser: NonNullable<MiniAppSession['telegramUser']> = {
export const demoDashboard: MiniAppDashboard = {
period: '2026-03',
currency: 'GEL',
timezone: 'Asia/Tbilisi',
rentDueDay: 20,
utilitiesDueDay: 4,
paymentBalanceAdjustmentPolicy: 'utilities',
totalDueMajor: '2410.00',
totalPaidMajor: '650.00',

View File

@@ -58,17 +58,25 @@ export const dictionary = {
ledgerEntries: 'Ledger entries',
pendingRequests: 'Pending requests',
yourBalanceTitle: 'Your balance',
yourBalanceBody:
'See rent, pure utilities, purchase balance adjustment, and what is still left to pay.',
payNowTitle: 'Pay now',
payNowBody:
'Your current-cycle summary stays here so you can see the number that matters first.',
yourBalanceBody: 'Current cycle breakdown.',
payNowTitle: 'This month',
payNowBody: '',
homeDueTitle: 'Due',
homeSettledTitle: 'Settled',
currentCycleLabel: 'Current cycle',
cycleTotalLabel: 'Cycle total',
cycleBillLabel: 'Cycle bill',
balanceAdjustmentLabel: 'Balance adjustment',
pureUtilitiesLabel: 'Pure utilities',
utilitiesBalanceLabel: 'Utilities + balance',
rentAdjustedTotalLabel: 'Rent after adjustment',
utilitiesAdjustedTotalLabel: 'Utilities after adjustment',
paidThisCycleLabel: 'Paid this cycle',
rentPaidLabel: 'Rent paid',
utilitiesPaidLabel: 'Utilities paid',
dueOnLabel: 'Due {date}',
upcomingLabel: 'Upcoming',
notBilledYetLabel: 'Not billed yet',
baseDue: 'Base due',
finalDue: 'Final due',
houseSnapshotTitle: 'House totals',
@@ -338,17 +346,25 @@ export const dictionary = {
ledgerEntries: 'Записи леджера',
pendingRequests: 'Ожидают подтверждения',
yourBalanceTitle: 'Твой баланс',
yourBalanceBody:
'Здесь отдельно видно аренду, чистую коммуналку, поправку по покупкам и то, что осталось оплатить.',
payNowTitle: 'К оплате сейчас',
payNowBody:
'Здесь остаётся только короткая сводка по текущему циклу, чтобы сразу видеть нужную сумму.',
yourBalanceBody: 'Разбор по текущему циклу.',
payNowTitle: 'Этот месяц',
payNowBody: '',
homeDueTitle: 'К оплате',
homeSettledTitle: 'Закрыто',
currentCycleLabel: 'Текущий цикл',
cycleTotalLabel: 'Всего за цикл',
cycleBillLabel: 'Счёт за цикл',
balanceAdjustmentLabel: 'Поправка по балансу',
pureUtilitiesLabel: 'Чистая коммуналка',
utilitiesBalanceLabel: 'Коммуналка + баланс',
rentAdjustedTotalLabel: 'Аренда после зачёта',
utilitiesAdjustedTotalLabel: 'Коммуналка после зачёта',
paidThisCycleLabel: 'Оплачено за цикл',
rentPaidLabel: 'По аренде оплачено',
utilitiesPaidLabel: 'По коммуналке оплачено',
dueOnLabel: 'Срок {date}',
upcomingLabel: 'Ещё не срок',
notBilledYetLabel: 'Ещё не начислено',
baseDue: 'База к оплате',
finalDue: 'Итог к оплате',
houseSnapshotTitle: 'Сводка по дому',

View File

@@ -490,6 +490,12 @@ button:disabled {
gap: 8px;
}
.balance-spotlight__copy small,
.balance-detail-row__main small {
color: #c6c2bb;
font-size: 0.86rem;
}
.balance-spotlight__hero {
display: grid;
gap: 6px;
@@ -1176,6 +1182,32 @@ button:disabled {
margin-top: 4px;
}
.balance-section {
gap: 16px;
}
.balance-section__header {
display: flex;
flex-wrap: wrap;
align-items: start;
justify-content: space-between;
gap: 12px;
}
.balance-section__copy {
display: grid;
gap: 8px;
}
.household-balance-list {
display: grid;
gap: 12px;
}
.household-balance-list__card .ledger-compact-card__meta {
margin-top: 12px;
}
.app-context-row {
display: flex;
flex-wrap: wrap;

View File

@@ -32,6 +32,48 @@ function formatCalendarDate(
}).format(new Date(Date.UTC(year, month - 1, day)))
}
function parsePeriod(period: string): { year: number; month: number } | null {
const [yearValue, monthValue] = period.split('-')
const year = Number.parseInt(yearValue ?? '', 10)
const month = Number.parseInt(monthValue ?? '', 10)
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) {
return null
}
return {
year,
month
}
}
function daysInMonth(year: number, month: number): number {
return new Date(Date.UTC(year, month, 0)).getUTCDate()
}
function formatTodayParts(timezone: string): { year: number; month: number; day: number } | null {
try {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(new Date())
const year = Number.parseInt(parts.find((part) => part.type === 'year')?.value ?? '', 10)
const month = Number.parseInt(parts.find((part) => part.type === 'month')?.value ?? '', 10)
const day = Number.parseInt(parts.find((part) => part.type === 'day')?.value ?? '', 10)
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
return null
}
return { year, month, day }
} catch {
return null
}
}
export function formatFriendlyDate(value: string, locale: Locale): string {
const calendarDateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
if (calendarDateMatch) {
@@ -61,19 +103,56 @@ export function formatFriendlyDate(value: string, locale: Locale): string {
}
export function formatCyclePeriod(period: string, locale: Locale): string {
const [yearValue, monthValue] = period.split('-')
const year = Number.parseInt(yearValue ?? '', 10)
const month = Number.parseInt(monthValue ?? '', 10)
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) {
const parsed = parsePeriod(period)
if (!parsed) {
return period
}
const date = new Date(Date.UTC(year, month - 1, 1))
const includeYear = year !== new Date().getUTCFullYear()
const date = new Date(Date.UTC(parsed.year, parsed.month - 1, 1))
const includeYear = parsed.year !== new Date().getUTCFullYear()
return new Intl.DateTimeFormat(localeTag(locale), {
month: 'long',
...(includeYear ? { year: 'numeric' } : {})
}).format(date)
}
export function formatPeriodDay(period: string, day: number, locale: Locale): string {
const parsed = parsePeriod(period)
if (!parsed) {
return period
}
const safeDay = Math.max(1, Math.min(day, daysInMonth(parsed.year, parsed.month)))
return (
formatCalendarDate(parsed.year, parsed.month, safeDay, locale) ??
`${formatCyclePeriod(period, locale)} ${safeDay}`
)
}
export function compareTodayToPeriodDay(
period: string,
day: number,
timezone: string
): -1 | 0 | 1 | null {
const parsed = parsePeriod(period)
const today = formatTodayParts(timezone)
if (!parsed || !today) {
return null
}
const safeDay = Math.max(1, Math.min(day, daysInMonth(parsed.year, parsed.month)))
const dueValue = Date.UTC(parsed.year, parsed.month - 1, safeDay)
const todayValue = Date.UTC(today.year, today.month - 1, today.day)
if (todayValue < dueValue) {
return -1
}
if (todayValue > dueValue) {
return 1
}
return 0
}

View File

@@ -2,6 +2,7 @@ const CURATED_TIMEZONES = [
'Asia/Tbilisi',
'Europe/Berlin',
'Europe/London',
'Europe/Moscow',
'Europe/Paris',
'Europe/Warsaw',
'America/New_York',

View File

@@ -95,6 +95,9 @@ export interface MiniAppTopicBinding {
export interface MiniAppDashboard {
period: string
currency: 'USD' | 'GEL'
timezone: string
rentDueDay: number
utilitiesDueDay: number
paymentBalanceAdjustmentPolicy: 'utilities' | 'rent' | 'separate'
totalDueMajor: string
totalPaidMajor: string

View File

@@ -107,46 +107,54 @@ export function BalancesScreen(props: Props) {
purchaseShareLabel: props.copy.purchaseShareLabel ?? ''
}}
/>
<article class="balance-item balance-item--wide">
<header>
<strong>{props.copy.householdBalancesTitle ?? ''}</strong>
<span>{String(dashboard().members.length)}</span>
<section class="balance-item balance-item--wide balance-section">
<header class="balance-section__header">
<div class="balance-section__copy">
<strong>{props.copy.householdBalancesTitle ?? ''}</strong>
<p>{props.copy.householdBalancesBody ?? ''}</p>
</div>
<span class="mini-chip mini-chip--muted">
{String(dashboard().members.length)} {props.copy.membersCount ?? ''}
</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 class="household-balance-list">
<For each={dashboard().members}>
{(member) => (
<article class="ledger-compact-card household-balance-list__card">
<div class="ledger-compact-card__main">
<header>
<strong>{member.displayName}</strong>
<span class={`balance-status ${props.memberRemainingClass(member)}`}>
{member.remainingMajor} {dashboard().currency}
</span>
</header>
<div class="ledger-compact-card__meta">
<span class="mini-chip mini-chip--muted">
{props.copy.baseDue ?? ''}: {props.memberBaseDueMajor(member)}{' '}
{dashboard().currency}
</span>
<span class="mini-chip mini-chip--muted">
{props.copy.shareRent ?? ''}: {member.rentShareMajor}{' '}
{dashboard().currency}
</span>
<span class="mini-chip mini-chip--muted">
{props.copy.shareUtilities ?? ''}: {member.utilityShareMajor}{' '}
{dashboard().currency}
</span>
<span class="mini-chip mini-chip--muted">
{props.copy.shareOffset ?? ''}: {member.purchaseOffsetMajor}{' '}
{dashboard().currency}
</span>
<span class="mini-chip mini-chip--muted">
{props.copy.paidLabel ?? ''}: {member.paidMajor} {dashboard().currency}
</span>
</div>
</div>
</article>
)}
</For>
</div>
</section>
</div>
)}
</Show>

View File

@@ -1,8 +1,8 @@
import { Show } from 'solid-js'
import { FinanceSummaryCards } from '../components/finance/finance-summary-cards'
import { formatCyclePeriod } from '../lib/dates'
import { sumMajorStrings } from '../lib/money'
import { compareTodayToPeriodDay, formatCyclePeriod, formatPeriodDay } from '../lib/dates'
import { majorStringToMinor, minorToMajorString, sumMajorStrings } from '../lib/money'
import type { MiniAppDashboard } from '../miniapp-api'
type Props = {
@@ -15,6 +15,43 @@ type Props = {
}
export function HomeScreen(props: Props) {
const rentPaidMajor = () => {
if (!props.dashboard || !props.currentMemberLine) {
return '0.00'
}
const totalMinor = props.dashboard.ledger
.filter(
(entry) =>
entry.kind === 'payment' &&
entry.memberId === props.currentMemberLine?.memberId &&
entry.paymentKind === 'rent'
)
.reduce((sum, entry) => sum + majorStringToMinor(entry.displayAmountMajor), 0n)
return minorToMajorString(totalMinor)
}
const utilitiesPaidMajor = () => {
if (!props.dashboard || !props.currentMemberLine) {
return '0.00'
}
const totalMinor = props.dashboard.ledger
.filter(
(entry) =>
entry.kind === 'payment' &&
entry.memberId === props.currentMemberLine?.memberId &&
entry.paymentKind === 'utilities'
)
.reduce((sum, entry) => sum + majorStringToMinor(entry.displayAmountMajor), 0n)
return minorToMajorString(totalMinor)
}
const hasUtilityBills = () =>
Boolean(props.dashboard?.ledger.some((entry) => entry.kind === 'utility'))
const adjustedRentMajor = () => {
if (!props.currentMemberLine) {
return null
@@ -37,6 +74,115 @@ export function HomeScreen(props: Props) {
)
}
const rentDueMajor = () => {
if (!props.currentMemberLine || !props.dashboard) {
return null
}
return props.dashboard.paymentBalanceAdjustmentPolicy === 'rent'
? adjustedRentMajor()
: props.currentMemberLine.rentShareMajor
}
const utilitiesDueMajor = () => {
if (!props.currentMemberLine || !props.dashboard || !hasUtilityBills()) {
return null
}
return props.dashboard.paymentBalanceAdjustmentPolicy === 'utilities'
? adjustedUtilitiesMajor()
: props.currentMemberLine.utilityShareMajor
}
const separateBalanceMajor = () => {
if (
!props.currentMemberLine ||
props.dashboard?.paymentBalanceAdjustmentPolicy !== 'separate'
) {
return null
}
return props.currentMemberLine.purchaseOffsetMajor
}
const heroState = () => {
if (!props.dashboard || !props.currentMemberLine) {
return {
title: props.copy.payNowTitle ?? props.copy.yourBalanceTitle ?? '',
label: props.copy.remainingLabel ?? '',
amountMajor: '—'
}
}
const remainingMinor = majorStringToMinor(props.currentMemberLine.remainingMajor)
const paidMinor = majorStringToMinor(props.currentMemberLine.paidMajor)
const rentStatus = compareTodayToPeriodDay(
props.dashboard.period,
props.dashboard.rentDueDay,
props.dashboard.timezone
)
const utilitiesStatus = compareTodayToPeriodDay(
props.dashboard.period,
props.dashboard.utilitiesDueDay,
props.dashboard.timezone
)
const hasDueNow =
(rentStatus !== null &&
rentStatus >= 0 &&
majorStringToMinor(rentDueMajor() ?? '0.00') > 0n) ||
(utilitiesStatus !== null &&
utilitiesStatus >= 0 &&
majorStringToMinor(utilitiesDueMajor() ?? '0.00') > 0n) ||
(props.dashboard.paymentBalanceAdjustmentPolicy === 'separate' &&
majorStringToMinor(separateBalanceMajor() ?? '0.00') > 0n)
if (remainingMinor === 0n && paidMinor > 0n) {
return {
title: props.copy.homeSettledTitle ?? '',
label: props.copy.paidThisCycleLabel ?? props.copy.paidLabel ?? '',
amountMajor: props.currentMemberLine.paidMajor
}
}
if (hasDueNow) {
return {
title: props.copy.homeDueTitle ?? props.copy.payNowTitle ?? '',
label: props.copy.remainingLabel ?? '',
amountMajor: props.currentMemberLine.remainingMajor
}
}
return {
title: props.copy.payNowTitle ?? props.copy.yourBalanceTitle ?? '',
label: props.copy.cycleTotalLabel ?? props.copy.totalDue ?? '',
amountMajor: props.currentMemberLine.netDueMajor
}
}
const dueLabel = (kind: 'rent' | 'utilities') => {
if (!props.dashboard) {
return null
}
const day = kind === 'rent' ? props.dashboard.rentDueDay : props.dashboard.utilitiesDueDay
const comparison = compareTodayToPeriodDay(
props.dashboard.period,
day,
props.dashboard.timezone
)
const date = formatPeriodDay(props.dashboard.period, day, props.locale)
const template =
comparison !== null && comparison < 0
? (props.copy.upcomingLabel ?? '')
: (props.copy.dueOnLabel ?? '').replace('{date}', date)
if (comparison !== null && comparison < 0) {
return `${template}${template.length > 0 ? ' ' : ''}${date}`.trim()
}
return template
}
return (
<Show
when={props.dashboard}
@@ -64,17 +210,14 @@ export function HomeScreen(props: Props) {
<article class="balance-item balance-item--accent home-pay-card">
<header class="home-pay-card__header">
<div class="home-pay-card__copy">
<strong>{props.copy.payNowTitle ?? props.copy.yourBalanceTitle ?? ''}</strong>
<p>{props.copy.payNowBody ?? ''}</p>
<strong>{heroState().title}</strong>
<small>{formatCyclePeriod(dashboard().period, props.locale)}</small>
</div>
<div class="balance-spotlight__hero">
<span>{props.copy.remainingLabel ?? ''}</span>
<span>{heroState().label}</span>
<strong>
{member().remainingMajor} {dashboard().currency}
{heroState().amountMajor} {dashboard().currency}
</strong>
<small>
{props.copy.totalDue ?? ''}: {member().netDueMajor} {dashboard().currency}
</small>
</div>
</header>
@@ -86,36 +229,66 @@ export function HomeScreen(props: Props) {
</strong>
</article>
<article class="stat-card balance-spotlight__stat">
<span>{props.copy.currentCycleLabel ?? ''}</span>
<strong>{formatCyclePeriod(dashboard().period, props.locale)}</strong>
<span>{props.copy.remainingLabel ?? ''}</span>
<strong>
{member().remainingMajor} {dashboard().currency}
</strong>
</article>
</div>
<div class="home-pay-card__chips">
<span class="mini-chip">
{dashboard().paymentBalanceAdjustmentPolicy === 'rent'
? props.copy.rentAdjustedTotalLabel
: props.copy.shareRent}
:{' '}
{dashboard().paymentBalanceAdjustmentPolicy === 'rent'
? adjustedRentMajor()
: member().rentShareMajor}{' '}
{dashboard().currency}
</span>
<span class="mini-chip mini-chip--muted">
{dashboard().paymentBalanceAdjustmentPolicy === 'utilities'
? props.copy.utilitiesAdjustedTotalLabel
: props.copy.shareUtilities}
:{' '}
{dashboard().paymentBalanceAdjustmentPolicy === 'utilities'
? adjustedUtilitiesMajor()
: member().utilityShareMajor}{' '}
{dashboard().currency}
</span>
<span class="mini-chip mini-chip--muted">
{props.copy.balanceAdjustmentLabel ?? props.copy.shareOffset}:{' '}
{member().purchaseOffsetMajor} {dashboard().currency}
</span>
<div class="balance-spotlight__rows">
<article class="balance-detail-row">
<div class="balance-detail-row__main">
<span>
{dashboard().paymentBalanceAdjustmentPolicy === 'rent'
? props.copy.rentAdjustedTotalLabel
: props.copy.shareRent}
</span>
<strong>
{rentDueMajor()} {dashboard().currency}
</strong>
<small>{dueLabel('rent')}</small>
</div>
<span class="mini-chip mini-chip--muted">
{props.copy.rentPaidLabel ?? props.copy.paidLabel}: {rentPaidMajor()}{' '}
{dashboard().currency}
</span>
</article>
<article class="balance-detail-row">
<div class="balance-detail-row__main">
<span>
{dashboard().paymentBalanceAdjustmentPolicy === 'utilities'
? props.copy.utilitiesAdjustedTotalLabel
: (props.copy.utilitiesBalanceLabel ?? props.copy.shareUtilities)}
</span>
<strong>
{utilitiesDueMajor() !== null
? `${utilitiesDueMajor()} ${dashboard().currency}`
: (props.copy.notBilledYetLabel ?? '')}
</strong>
<small>
{utilitiesDueMajor() !== null
? dueLabel('utilities')
: dueLabel('utilities')}
</small>
</div>
<span class="mini-chip mini-chip--muted">
{props.copy.utilitiesPaidLabel ?? props.copy.paidLabel}:{' '}
{utilitiesPaidMajor()} {dashboard().currency}
</span>
</article>
<Show when={dashboard().paymentBalanceAdjustmentPolicy === 'separate'}>
<article class="balance-detail-row">
<div class="balance-detail-row__main">
<span>{props.copy.balanceAdjustmentLabel ?? props.copy.shareOffset}</span>
<strong>
{separateBalanceMajor()} {dashboard().currency}
</strong>
</div>
</article>
</Show>
</div>
</article>
)}