diff --git a/apps/miniapp/src/components/ui/skeleton.tsx b/apps/miniapp/src/components/ui/skeleton.tsx index 49652c1..9a87df5 100644 --- a/apps/miniapp/src/components/ui/skeleton.tsx +++ b/apps/miniapp/src/components/ui/skeleton.tsx @@ -1,9 +1,11 @@ +import { JSX } from 'solid-js' import { cn } from '../../lib/cn' type SkeletonProps = { class?: string width?: string height?: string + style?: JSX.CSSProperties } export function Skeleton(props: SkeletonProps) { @@ -12,7 +14,8 @@ export function Skeleton(props: SkeletonProps) { class={cn('ui-skeleton', props.class)} style={{ width: props.width, - height: props.height + height: props.height, + ...props.style }} /> ) diff --git a/apps/miniapp/src/contexts/dashboard-context.tsx b/apps/miniapp/src/contexts/dashboard-context.tsx index 89eae1a..dac8a81 100644 --- a/apps/miniapp/src/contexts/dashboard-context.tsx +++ b/apps/miniapp/src/contexts/dashboard-context.tsx @@ -72,6 +72,7 @@ type DashboardContextValue = { setDashboard: ( value: MiniAppDashboard | null | ((prev: MiniAppDashboard | null) => MiniAppDashboard | null) ) => void + loading: () => boolean adminSettings: () => MiniAppAdminSettingsPayload | null setAdminSettings: ( value: @@ -246,6 +247,7 @@ export function DashboardProvider(props: ParentProps) { const { copy } = useI18n() const [dashboard, setDashboard] = createSignal(null) + const [loading, setLoading] = createSignal(true) const [adminSettings, setAdminSettings] = createSignal(null) const [cycleState, setCycleState] = createSignal(null) const [pendingMembers, setPendingMembers] = createSignal([]) @@ -299,9 +301,11 @@ export function DashboardProvider(props: ParentProps) { onCleanup(unregisterDashboardRefreshListener) async function loadDashboardData(initData: string, isAdmin: boolean) { - // In demo mode, use demo data + setLoading(true) + if (!initData) { applyDemoState() + setLoading(false) return } @@ -331,6 +335,8 @@ export function DashboardProvider(props: ParentProps) { } } } + + setLoading(false) } function applyDemoState() { @@ -345,6 +351,7 @@ export function DashboardProvider(props: ParentProps) { value={{ dashboard, setDashboard, + loading, adminSettings, setAdminSettings, cycleState, diff --git a/apps/miniapp/src/routes/balances.tsx b/apps/miniapp/src/routes/balances.tsx index 52d0378..34f9b92 100644 --- a/apps/miniapp/src/routes/balances.tsx +++ b/apps/miniapp/src/routes/balances.tsx @@ -1,151 +1,169 @@ -import { Show, For } from 'solid-js' +import { Show, For, Switch, Match } from 'solid-js' import { BarChart3 } from 'lucide-solid' import { useI18n } from '../contexts/i18n-context' import { useDashboard } from '../contexts/dashboard-context' import { Card } from '../components/ui/card' +import { Skeleton } from '../components/ui/skeleton' import { memberRemainingClass } from '../lib/ledger-helpers' export default function BalancesRoute() { const { copy } = useI18n() - const { dashboard, memberBalanceVisuals, purchaseInvestmentChart } = useDashboard() + const { dashboard, loading, memberBalanceVisuals, purchaseInvestmentChart } = useDashboard() return (
- + + +
+ + +
+
+ +
+
+ + + + +
+ +

{copy().balancesEmpty}

- } - > - {(data) => ( - <> - {/* ── Household balances ─────────────────── */} - -
- {copy().householdBalancesTitle} -

{copy().householdBalancesBody}

-
-
- - {(member) => ( -
- {member.displayName} -
- - {member.netDueMajor} {data().currency} - - - {member.remainingMajor} {data().currency} - -
-
- )} -
-
-
+
- {/* ── Balance breakdown bars ──────────────── */} - -
- - {copy().financeVisualsTitle} -

{copy().financeVisualsBody}

-
-
- - {(item) => ( -
- {item.member.displayName} -
- - {(segment) => ( -
- )} - -
- - {item.member.remainingMajor} {data().currency} - -
- )} - -
- - {copy().shareRent} - - - {copy().shareUtilities} - - - {copy().shareOffset} - + + {(data) => ( + <> + {/* ── Household balances ─────────────────── */} + +
+ {copy().householdBalancesTitle} +

{copy().householdBalancesBody}

-
- - - {/* ── Purchase investment donut ───────────── */} - -
- {copy().purchaseInvestmentsTitle} -

{copy().purchaseInvestmentsBody}

-
- 0} - fallback={

{copy().purchaseInvestmentsEmpty}

} - > -
- - - {(slice) => ( - - )} - - - {purchaseInvestmentChart().totalMajor} - - - {data().currency} - - -
- - {(slice) => ( -
- - {slice.label} - - {slice.amountMajor} ({slice.percentage}%) - +
+ + {(member) => ( +
+ {member.displayName} +
+ + {member.netDueMajor} {data().currency} + + + {member.remainingMajor} {data().currency} +
- )} - +
+ )} +
+
+ + + {/* ── Balance breakdown bars ──────────────── */} + +
+ + {copy().financeVisualsTitle} +

{copy().financeVisualsBody}

+
+
+ + {(item) => ( +
+ {item.member.displayName} +
+ + {(segment) => ( +
+ )} + +
+ + {item.member.remainingMajor} {data().currency} + +
+ )} + +
+ + {copy().shareRent} + + + {copy().shareUtilities} + + + {copy().shareOffset} +
- - - - )} - + + + {/* ── Purchase investment donut ───────────── */} + +
+ {copy().purchaseInvestmentsTitle} +

{copy().purchaseInvestmentsBody}

+
+ 0} + fallback={

{copy().purchaseInvestmentsEmpty}

} + > +
+ + + {(slice) => ( + + )} + + + {purchaseInvestmentChart().totalMajor} + + + {data().currency} + + +
+ + {(slice) => ( +
+ + {slice.label} + + {slice.amountMajor} ({slice.percentage}%) + +
+ )} +
+
+
+
+
+ + )} + +
) } diff --git a/apps/miniapp/src/routes/home.tsx b/apps/miniapp/src/routes/home.tsx index d93b49b..c35c431 100644 --- a/apps/miniapp/src/routes/home.tsx +++ b/apps/miniapp/src/routes/home.tsx @@ -1,4 +1,4 @@ -import { Show, For, createMemo, createSignal } from 'solid-js' +import { Show, For, createMemo, createSignal, Switch, Match } from 'solid-js' import { Clock, ChevronDown, ChevronUp, Copy, Check, CreditCard } from 'lucide-solid' import { useNavigate } from '@solidjs/router' @@ -12,6 +12,7 @@ import { Field } from '../components/ui/field' import { Input } from '../components/ui/input' import { Modal } from '../components/ui/dialog' import { Toast } from '../components/ui/toast' +import { Skeleton } from '../components/ui/skeleton' import { memberRemainingClass, ledgerPrimaryAmount } from '../lib/ledger-helpers' import { majorStringToMinor, minorToMajorString } from '../lib/money' import { @@ -29,6 +30,7 @@ export default function HomeRoute() { const { copy, locale } = useI18n() const { dashboard, + loading, currentMemberLine, utilityLedger, utilityTotalMajor, @@ -269,600 +271,640 @@ export default function HomeRoute() {
{/* ── Dashboard stats ─────────────────────────── */} - + + +
+
+ +
+
+ +
+ +
+ +
+
+ + +
+
+ +
+
+ +
+ +
+
+ + + +

{copy().emptyDashboard}

- } - > - {(data) => ( - <> - - {(member) => { - const policy = () => data().paymentBalanceAdjustmentPolicy +
- const rentBaseMinor = () => majorStringToMinor(member().rentShareMajor) - const utilitiesBaseMinor = () => majorStringToMinor(member().utilityShareMajor) - const purchaseOffsetMinor = () => majorStringToMinor(member().purchaseOffsetMajor) + + {(data) => ( + <> + + {(member) => { + const policy = () => data().paymentBalanceAdjustmentPolicy - const rentProposalMinor = () => - policy() === 'rent' ? rentBaseMinor() + purchaseOffsetMinor() : rentBaseMinor() - const utilitiesProposalMinor = () => - policy() === 'utilities' - ? utilitiesBaseMinor() + purchaseOffsetMinor() - : utilitiesBaseMinor() + const rentBaseMinor = () => majorStringToMinor(member().rentShareMajor) + const utilitiesBaseMinor = () => majorStringToMinor(member().utilityShareMajor) + const purchaseOffsetMinor = () => majorStringToMinor(member().purchaseOffsetMajor) - const mode = () => homeMode() - const currency = () => data().currency - const timezone = () => data().timezone - const period = () => effectivePeriod() ?? data().period - const today = () => todayOverride() + const rentProposalMinor = () => + policy() === 'rent' ? rentBaseMinor() + purchaseOffsetMinor() : rentBaseMinor() + const utilitiesProposalMinor = () => + policy() === 'utilities' + ? utilitiesBaseMinor() + purchaseOffsetMinor() + : utilitiesBaseMinor() - function upcomingDay(day: number): { dateLabel: string; daysUntil: number | null } { - const withinPeriodDays = daysUntilPeriodDay(period(), day, timezone(), today()) - if (withinPeriodDays === null) { - return { dateLabel: '—', daysUntil: null } - } + const mode = () => homeMode() + const currency = () => data().currency + const timezone = () => data().timezone + const period = () => effectivePeriod() ?? data().period + const today = () => todayOverride() + + function upcomingDay(day: number): { + dateLabel: string + daysUntil: number | null + } { + const withinPeriodDays = daysUntilPeriodDay(period(), day, timezone(), today()) + if (withinPeriodDays === null) { + return { dateLabel: '—', daysUntil: null } + } + + if (withinPeriodDays >= 0) { + return { + dateLabel: formatPeriodDay(period(), day, locale()), + daysUntil: withinPeriodDays + } + } + + const next = nextCyclePeriod(period()) + if (!next) { + return { + dateLabel: formatPeriodDay(period(), day, locale()), + daysUntil: null + } + } - if (withinPeriodDays >= 0) { return { - dateLabel: formatPeriodDay(period(), day, locale()), - daysUntil: withinPeriodDays + dateLabel: formatPeriodDay(next, day, locale()), + daysUntil: daysUntilPeriodDay(next, day, timezone(), today()) } } - const next = nextCyclePeriod(period()) - if (!next) { - return { dateLabel: formatPeriodDay(period(), day, locale()), daysUntil: null } + const rentDueDate = () => formatPeriodDay(period(), data().rentDueDay, locale()) + const utilitiesDueDate = () => + formatPeriodDay(period(), data().utilitiesDueDay, locale()) + + const rentDaysUntilDue = () => + daysUntilPeriodDay(period(), data().rentDueDay, timezone(), today()) + const utilitiesDaysUntilDue = () => + daysUntilPeriodDay(period(), data().utilitiesDueDay, timezone(), today()) + + const rentUpcoming = () => upcomingDay(data().rentWarningDay) + const utilitiesUpcoming = () => upcomingDay(data().utilitiesReminderDay) + + const focusBadge = () => { + const badge = dueStatusBadge() + return badge ? {badge.label} : null } - return { - dateLabel: formatPeriodDay(next, day, locale()), - daysUntil: daysUntilPeriodDay(next, day, timezone(), today()) + const dueBadge = (days: number | null) => { + if (days === null) return null + if (days < 0) return {copy().overdueLabel} + if (days === 0) return {copy().dueTodayLabel} + return ( + + {copy().daysLeftLabel.replace('{count}', String(days))} + + ) } - } - const rentDueDate = () => formatPeriodDay(period(), data().rentDueDay, locale()) - const utilitiesDueDate = () => - formatPeriodDay(period(), data().utilitiesDueDay, locale()) - - const rentDaysUntilDue = () => - daysUntilPeriodDay(period(), data().rentDueDay, timezone(), today()) - const utilitiesDaysUntilDue = () => - daysUntilPeriodDay(period(), data().utilitiesDueDay, timezone(), today()) - - const rentUpcoming = () => upcomingDay(data().rentWarningDay) - const utilitiesUpcoming = () => upcomingDay(data().utilitiesReminderDay) - - const focusBadge = () => { - const badge = dueStatusBadge() - return badge ? {badge.label} : null - } - - const dueBadge = (days: number | null) => { - if (days === null) return null - if (days < 0) return {copy().overdueLabel} - if (days === 0) return {copy().dueTodayLabel} return ( - - {copy().daysLeftLabel.replace('{count}', String(days))} - - ) - } - - return ( - <> - - -
-
- {copy().homeUtilitiesTitle} -
- {focusBadge()} - -
-
-
-
- {copy().finalDue} - - {minorToMajorString(utilitiesProposalMinor())} {currency()} - -
-
- {copy().dueOnLabel.replace('{date}', utilitiesDueDate())} - {dueBadge(utilitiesDaysUntilDue())} -
-
- {copy().baseDue} - - {member().utilityShareMajor} {currency()} - -
- -
- {copy().balanceAdjustmentLabel} - - {member().purchaseOffsetMajor} {currency()} - -
-
- 0}> -
- {copy().homeUtilitiesBillsTitle} - - {utilityTotalMajor()} {currency()} - -
- - {(entry) => ( -
- {entry.title} - {ledgerPrimaryAmount(entry)} -
- )} -
-
-
-
-
-
- - - -
-
- {copy().homeRentTitle} -
- {focusBadge()} - -
-
-
-
- {copy().finalDue} - - {minorToMajorString(rentProposalMinor())} {currency()} - -
-
- {copy().dueOnLabel.replace('{date}', rentDueDate())} - {dueBadge(rentDaysUntilDue())} -
-
- {copy().baseDue} - - {member().rentShareMajor} {currency()} - -
- -
- {copy().balanceAdjustmentLabel} - - {member().purchaseOffsetMajor} {currency()} - -
-
-
-
-
-
- - - -
-
- {copy().homeNoPaymentTitle} -
-
-
- - {copy().homeUtilitiesUpcomingLabel.replace( - '{date}', - utilitiesUpcoming().dateLabel - )} - - - {utilitiesUpcoming().daysUntil !== null - ? copy().daysLeftLabel.replace( - '{count}', - String(utilitiesUpcoming().daysUntil) - ) - : '—'} - -
-
- - {copy().homeRentUpcomingLabel.replace( - '{date}', - rentUpcoming().dateLabel - )} - - - {rentUpcoming().daysUntil !== null - ? copy().daysLeftLabel.replace( - '{count}', - String(rentUpcoming().daysUntil) - ) - : '—'} - -
-
-
-
-
- - - -
-
- {copy().homeFillUtilitiesTitle} -
-

{copy().homeFillUtilitiesBody}

-
- - - setUtilityDraft((d) => ({ - ...d, - billName: e.currentTarget.value - })) - } - /> - - - - setUtilityDraft((d) => ({ - ...d, - amountMajor: e.currentTarget.value - })) - } - /> - -
- - -
-
-
-
-
- - -
- - {(destination) => ( - -
-
- {destination.label} -
-
- - {(value) => ( -
- {copy().rentPaymentDestinationRecipient} - - - -
- )} -
- - {(value) => ( -
- {copy().rentPaymentDestinationBank} - - - -
- )} -
-
- {copy().rentPaymentDestinationAccount} - - - -
- - {(value) => ( -
- {copy().rentPaymentDestinationLink} - - - -
- )} -
- - {(value) => ( -
- {copy().rentPaymentDestinationNote} - - - -
- )} -
-
-
-
- )} -
-
-
- - ) - }} -
- - {/* Your balance card */} - - {(member) => ( - <> - - {(() => { - const subtotalMinor = - majorStringToMinor(member().rentShareMajor) + - majorStringToMinor(member().utilityShareMajor) - const subtotalMajor = minorToMajorString(subtotalMinor) - - return ( - + <> + +
- {copy().yourBalanceTitle} - - {(badge) => ( - {badge().label} - )} + {copy().homeUtilitiesTitle} +
+ {focusBadge()} + +
+
+
+
+ {copy().finalDue} + + {minorToMajorString(utilitiesProposalMinor())} {currency()} + +
+
+ + {copy().dueOnLabel.replace('{date}', utilitiesDueDate())} + + {dueBadge(utilitiesDaysUntilDue())} +
+
+ {copy().baseDue} + + {member().utilityShareMajor} {currency()} + +
+ +
+ {copy().balanceAdjustmentLabel} + + {member().purchaseOffsetMajor} {currency()} + +
+ 0}> +
+ {copy().homeUtilitiesBillsTitle} + + {utilityTotalMajor()} {currency()} + +
+ + {(entry) => ( +
+ {entry.title} + {ledgerPrimaryAmount(entry)} +
+ )} +
+
+
+
+
+
+ + + +
+
+ {copy().homeRentTitle} +
+ {focusBadge()} + +
+
+
+
+ {copy().finalDue} + + {minorToMajorString(rentProposalMinor())} {currency()} + +
+
+ {copy().dueOnLabel.replace('{date}', rentDueDate())} + {dueBadge(rentDaysUntilDue())} +
+
+ {copy().baseDue} + + {member().rentShareMajor} {currency()} + +
+ +
+ {copy().balanceAdjustmentLabel} + + {member().purchaseOffsetMajor} {currency()} + +
+
+
+
+
+
+ + + +
+
+ {copy().homeNoPaymentTitle}
- {copy().shareRent} + + {copy().homeUtilitiesUpcomingLabel.replace( + '{date}', + utilitiesUpcoming().dateLabel + )} + - {member().rentShareMajor} {data().currency} + {utilitiesUpcoming().daysUntil !== null + ? copy().daysLeftLabel.replace( + '{count}', + String(utilitiesUpcoming().daysUntil) + ) + : '—'}
- {copy().shareUtilities} + + {copy().homeRentUpcomingLabel.replace( + '{date}', + rentUpcoming().dateLabel + )} + - {member().utilityShareMajor} {data().currency} - -
-
- {copy().totalDueLabel} - - {subtotalMajor} {data().currency} - -
-
- {copy().balanceAdjustmentLabel} - - {member().purchaseOffsetMajor} {data().currency} - -
-
- {copy().remainingLabel} - - {member().remainingMajor} {data().currency} + {rentUpcoming().daysUntil !== null + ? copy().daysLeftLabel.replace( + '{count}', + String(rentUpcoming().daysUntil) + ) + : '—'}
- ) - })()} -
+
- - -
-
- {copy().homePurchasesTitle} + + +
+
+ + {copy().homeFillUtilitiesTitle} + +
+

{copy().homeFillUtilitiesBody}

+
+ + + setUtilityDraft((d) => ({ + ...d, + billName: e.currentTarget.value + })) + } + /> + + + + setUtilityDraft((d) => ({ + ...d, + amountMajor: e.currentTarget.value + })) + } + /> + +
+ + +
+
+
+
+
+ + +
+ + {(destination) => ( + +
+
+ {destination.label} +
+
+ + {(value) => ( +
+ {copy().rentPaymentDestinationRecipient} + + + +
+ )} +
+ + {(value) => ( +
+ {copy().rentPaymentDestinationBank} + + + +
+ )} +
+
+ {copy().rentPaymentDestinationAccount} + + + +
+ + {(value) => ( +
+ {copy().rentPaymentDestinationLink} + + + +
+ )} +
+ + {(value) => ( +
+ {copy().rentPaymentDestinationNote} + + + +
+ )} +
+
+
+
+ )} +
-
-
- {copy().homePurchasesOffsetLabel} - - {member().purchaseOffsetMajor} {data().currency} - + + + ) + }} + + + {/* Your balance card */} + + {(member) => ( + <> + + {(() => { + const subtotalMinor = + majorStringToMinor(member().rentShareMajor) + + majorStringToMinor(member().utilityShareMajor) + const subtotalMajor = minorToMajorString(subtotalMinor) + + return ( + +
+
+ {copy().yourBalanceTitle} + + {(badge) => ( + {badge().label} + )} + +
+
+
+ {copy().shareRent} + + {member().rentShareMajor} {data().currency} + +
+
+ {copy().shareUtilities} + + {member().utilityShareMajor} {data().currency} + +
+
+ {copy().totalDueLabel} + + {subtotalMajor} {data().currency} + +
+
+ {copy().balanceAdjustmentLabel} + + {member().purchaseOffsetMajor} {data().currency} + +
+
+ {copy().remainingLabel} + + {member().remainingMajor} {data().currency} + +
+
+
+
+ ) + })()} +
+ + + +
+
+ {copy().homePurchasesTitle}
-
- - {copy().homePurchasesTotalLabel.replace( - '{count}', - String(purchaseLedger().length) - )} - - - {purchaseTotalMajor()} {data().currency} - -
-
- {copy().homeMembersCountLabel} - {data().members.length} +
+
+ {copy().homePurchasesOffsetLabel} + + {member().purchaseOffsetMajor} {data().currency} + +
+
+ + {copy().homePurchasesTotalLabel.replace( + '{count}', + String(purchaseLedger().length) + )} + + + {purchaseTotalMajor()} {data().currency} + +
+
+ {copy().homeMembersCountLabel} + {data().members.length} +
-
-
-
- - )} -
+ + + + )} + - {/* Rent FX card */} - - -
- {copy().rentFxTitle} -
- {copy().sourceAmountLabel} - - {data().rentSourceAmountMajor} {data().rentSourceCurrency} - -
-
- {copy().settlementAmountLabel} - - {data().rentDisplayAmountMajor} {data().currency} - -
- -
- {copy().fxEffectiveDateLabel} - {data().rentFxEffectiveDate} + {/* Rent FX card */} + + +
+ {copy().rentFxTitle} +
+ {copy().sourceAmountLabel} + + {data().rentSourceAmountMajor} {data().rentSourceCurrency} +
+
+ {copy().settlementAmountLabel} + + {data().rentDisplayAmountMajor} {data().currency} + +
+ +
+ {copy().fxEffectiveDateLabel} + {data().rentFxEffectiveDate} +
+
+
+
+
+ + {/* Latest activity */} + +
+
+ + {copy().latestActivityTitle} +
+ 0} + fallback={

{copy().latestActivityEmpty}

} + > +
+ + {(entry) => ( +
+ {entry.title} + {ledgerPrimaryAmount(entry)} +
+ )} +
+
+ 5}> + +
- - - {/* Latest activity */} - -
-
- - {copy().latestActivityTitle} -
- 0} - fallback={

{copy().latestActivityEmpty}

} - > -
- - {(entry) => ( -
- {entry.title} - {ledgerPrimaryAmount(entry)} -
- )} -
-
- 5}> - - -
-
-
- - )} - + + )} + + {/* Quick Payment Modal */} - + + + + + + + + + + + +

{copy().ledgerEmpty}

- } - > - {(_data) => ( - <> - {/* ── Purchases ──────────────────────────── */} - - -
- -
-
- 0} - fallback={

{copy().purchasesEmpty}

} - > -
- - {(entry) => ( - - )} - -
-
-
+
- {/* ── Utility bills ──────────────────────── */} - - -
- -
-
- 0} - fallback={

{copy().utilityLedgerEmpty}

} + + {(_data) => ( + <> + {/* ── Purchases ──────────────────────────── */} + -
- - {(entry) => ( - - )} - -
-
-
+ +
+ +
+
+ 0} + fallback={

{copy().purchasesEmpty}

} + > +
+ + {(entry) => ( + + )} + +
+
+ - {/* ── Payments ───────────────────────────── */} - - -
- -
-
- 0} - fallback={

{copy().paymentsEmpty}

} + {/* ── Utility bills ──────────────────────── */} + + +
+ +
+
+ 0} + fallback={

{copy().utilityLedgerEmpty}

} + > +
+ + {(entry) => ( + + )} + +
+
+
+ + {/* ── Payments ───────────────────────────── */} + -
- - {(entry) => ( - - )} - -
-
-
- - )} -
+ +
+ +
+
+ 0} + fallback={

{copy().paymentsEmpty}

} + > +
+ + {(entry) => ( + + )} + +
+
+ + + )} + + {/* ──────── Add Purchase Modal (Bug #4 fix) ──── */}