feat: add skeleton loading states for initial data load

- Add loading signal to DashboardContext
- Update Skeleton component to accept style prop
- Add skeleton UI to HomeRoute, LedgerRoute, and BalancesRoute
- Use Switch/Match pattern for three-state handling (loading/empty/content)
This commit is contained in:
2026-03-14 11:08:35 +04:00
parent 9cac339d0a
commit 62d62091a3
5 changed files with 919 additions and 832 deletions

View File

@@ -1,9 +1,11 @@
import { JSX } from 'solid-js'
import { cn } from '../../lib/cn' import { cn } from '../../lib/cn'
type SkeletonProps = { type SkeletonProps = {
class?: string class?: string
width?: string width?: string
height?: string height?: string
style?: JSX.CSSProperties
} }
export function Skeleton(props: SkeletonProps) { export function Skeleton(props: SkeletonProps) {
@@ -12,7 +14,8 @@ export function Skeleton(props: SkeletonProps) {
class={cn('ui-skeleton', props.class)} class={cn('ui-skeleton', props.class)}
style={{ style={{
width: props.width, width: props.width,
height: props.height height: props.height,
...props.style
}} }}
/> />
) )

View File

@@ -72,6 +72,7 @@ type DashboardContextValue = {
setDashboard: ( setDashboard: (
value: MiniAppDashboard | null | ((prev: MiniAppDashboard | null) => MiniAppDashboard | null) value: MiniAppDashboard | null | ((prev: MiniAppDashboard | null) => MiniAppDashboard | null)
) => void ) => void
loading: () => boolean
adminSettings: () => MiniAppAdminSettingsPayload | null adminSettings: () => MiniAppAdminSettingsPayload | null
setAdminSettings: ( setAdminSettings: (
value: value:
@@ -246,6 +247,7 @@ export function DashboardProvider(props: ParentProps) {
const { copy } = useI18n() const { copy } = useI18n()
const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null) const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null)
const [loading, setLoading] = createSignal(true)
const [adminSettings, setAdminSettings] = createSignal<MiniAppAdminSettingsPayload | null>(null) const [adminSettings, setAdminSettings] = createSignal<MiniAppAdminSettingsPayload | null>(null)
const [cycleState, setCycleState] = createSignal<MiniAppAdminCycleState | null>(null) const [cycleState, setCycleState] = createSignal<MiniAppAdminCycleState | null>(null)
const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([]) const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([])
@@ -299,9 +301,11 @@ export function DashboardProvider(props: ParentProps) {
onCleanup(unregisterDashboardRefreshListener) onCleanup(unregisterDashboardRefreshListener)
async function loadDashboardData(initData: string, isAdmin: boolean) { async function loadDashboardData(initData: string, isAdmin: boolean) {
// In demo mode, use demo data setLoading(true)
if (!initData) { if (!initData) {
applyDemoState() applyDemoState()
setLoading(false)
return return
} }
@@ -331,6 +335,8 @@ export function DashboardProvider(props: ParentProps) {
} }
} }
} }
setLoading(false)
} }
function applyDemoState() { function applyDemoState() {
@@ -345,6 +351,7 @@ export function DashboardProvider(props: ParentProps) {
value={{ value={{
dashboard, dashboard,
setDashboard, setDashboard,
loading,
adminSettings, adminSettings,
setAdminSettings, setAdminSettings,
cycleState, cycleState,

View File

@@ -1,151 +1,169 @@
import { Show, For } from 'solid-js' import { Show, For, Switch, Match } from 'solid-js'
import { BarChart3 } from 'lucide-solid' import { BarChart3 } from 'lucide-solid'
import { useI18n } from '../contexts/i18n-context' import { useI18n } from '../contexts/i18n-context'
import { useDashboard } from '../contexts/dashboard-context' import { useDashboard } from '../contexts/dashboard-context'
import { Card } from '../components/ui/card' import { Card } from '../components/ui/card'
import { Skeleton } from '../components/ui/skeleton'
import { memberRemainingClass } from '../lib/ledger-helpers' import { memberRemainingClass } from '../lib/ledger-helpers'
export default function BalancesRoute() { export default function BalancesRoute() {
const { copy } = useI18n() const { copy } = useI18n()
const { dashboard, memberBalanceVisuals, purchaseInvestmentChart } = useDashboard() const { dashboard, loading, memberBalanceVisuals, purchaseInvestmentChart } = useDashboard()
return ( return (
<div class="route route--balances"> <div class="route route--balances">
<Show <Switch>
when={dashboard()} <Match when={loading()}>
fallback={ <Card>
<div class="section-header">
<Skeleton style={{ width: '180px', height: '20px' }} />
<Skeleton style={{ width: '100%', height: '16px', 'margin-top': '8px' }} />
</div>
<div style={{ 'margin-top': '16px' }}>
<Skeleton style={{ width: '100%', height: '120px' }} />
</div>
</Card>
<Card>
<Skeleton style={{ width: '200px', height: '20px' }} />
<Skeleton style={{ width: '100%', height: '80px', 'margin-top': '16px' }} />
</Card>
</Match>
<Match when={!dashboard()}>
<Card> <Card>
<p class="empty-state">{copy().balancesEmpty}</p> <p class="empty-state">{copy().balancesEmpty}</p>
</Card> </Card>
} </Match>
>
{(data) => (
<>
{/* ── Household balances ─────────────────── */}
<Card>
<div class="section-header">
<strong>{copy().householdBalancesTitle}</strong>
<p>{copy().householdBalancesBody}</p>
</div>
<div class="member-balance-list">
<For each={data().members}>
{(member) => (
<div class={`member-balance-row ${memberRemainingClass(member)}`}>
<span class="member-balance-row__name">{member.displayName}</span>
<div class="member-balance-row__amounts">
<span class="member-balance-row__due">
{member.netDueMajor} {data().currency}
</span>
<span class="member-balance-row__remaining">
{member.remainingMajor} {data().currency}
</span>
</div>
</div>
)}
</For>
</div>
</Card>
{/* ── Balance breakdown bars ──────────────── */} <Match when={dashboard()}>
<Card> {(data) => (
<div class="section-header"> <>
<BarChart3 size={16} /> {/* ── Household balances ─────────────────── */}
<strong>{copy().financeVisualsTitle}</strong> <Card>
<p>{copy().financeVisualsBody}</p> <div class="section-header">
</div> <strong>{copy().householdBalancesTitle}</strong>
<div class="balance-visuals"> <p>{copy().householdBalancesBody}</p>
<For each={memberBalanceVisuals()}>
{(item) => (
<div class="balance-bar-row">
<span class="balance-bar-row__name">{item.member.displayName}</span>
<div
class="balance-bar-row__track"
style={{ width: `${Math.max(item.barWidthPercent, 8)}%` }}
>
<For each={item.segments}>
{(segment) => (
<div
class={`balance-bar-row__segment balance-bar-row__segment--${segment.key}`}
style={{ width: `${segment.widthPercent}%` }}
title={`${segment.label}: ${segment.amountMajor} ${data().currency}`}
/>
)}
</For>
</div>
<span class={`balance-bar-row__label ${memberRemainingClass(item.member)}`}>
{item.member.remainingMajor} {data().currency}
</span>
</div>
)}
</For>
<div class="balance-bar-legend">
<span class="balance-bar-legend__item balance-bar-legend__item--rent">
{copy().shareRent}
</span>
<span class="balance-bar-legend__item balance-bar-legend__item--utilities">
{copy().shareUtilities}
</span>
<span class="balance-bar-legend__item balance-bar-legend__item--purchase">
{copy().shareOffset}
</span>
</div> </div>
</div> <div class="member-balance-list">
</Card> <For each={data().members}>
{(member) => (
{/* ── Purchase investment donut ───────────── */} <div class={`member-balance-row ${memberRemainingClass(member)}`}>
<Card> <span class="member-balance-row__name">{member.displayName}</span>
<div class="section-header"> <div class="member-balance-row__amounts">
<strong>{copy().purchaseInvestmentsTitle}</strong> <span class="member-balance-row__due">
<p>{copy().purchaseInvestmentsBody}</p> {member.netDueMajor} {data().currency}
</div> </span>
<Show <span class="member-balance-row__remaining">
when={purchaseInvestmentChart().slices.length > 0} {member.remainingMajor} {data().currency}
fallback={<p class="empty-state">{copy().purchaseInvestmentsEmpty}</p>} </span>
>
<div class="donut-chart">
<svg viewBox="0 0 100 100" class="donut-chart__svg">
<For each={purchaseInvestmentChart().slices}>
{(slice) => (
<circle
cx="50"
cy="50"
r="42"
fill="none"
stroke={slice.color}
stroke-width="12"
stroke-dasharray={slice.dasharray}
stroke-dashoffset={slice.dashoffset}
class="donut-chart__slice"
/>
)}
</For>
<text x="50" y="48" text-anchor="middle" class="donut-chart__total">
{purchaseInvestmentChart().totalMajor}
</text>
<text x="50" y="58" text-anchor="middle" class="donut-chart__label">
{data().currency}
</text>
</svg>
<div class="donut-chart__legend">
<For each={purchaseInvestmentChart().slices}>
{(slice) => (
<div class="donut-chart__legend-item">
<span class="donut-chart__color" style={{ background: slice.color }} />
<span>{slice.label}</span>
<strong>
{slice.amountMajor} ({slice.percentage}%)
</strong>
</div> </div>
)} </div>
</For> )}
</For>
</div>
</Card>
{/* ── Balance breakdown bars ──────────────── */}
<Card>
<div class="section-header">
<BarChart3 size={16} />
<strong>{copy().financeVisualsTitle}</strong>
<p>{copy().financeVisualsBody}</p>
</div>
<div class="balance-visuals">
<For each={memberBalanceVisuals()}>
{(item) => (
<div class="balance-bar-row">
<span class="balance-bar-row__name">{item.member.displayName}</span>
<div
class="balance-bar-row__track"
style={{ width: `${Math.max(item.barWidthPercent, 8)}%` }}
>
<For each={item.segments}>
{(segment) => (
<div
class={`balance-bar-row__segment balance-bar-row__segment--${segment.key}`}
style={{ width: `${segment.widthPercent}%` }}
title={`${segment.label}: ${segment.amountMajor} ${data().currency}`}
/>
)}
</For>
</div>
<span class={`balance-bar-row__label ${memberRemainingClass(item.member)}`}>
{item.member.remainingMajor} {data().currency}
</span>
</div>
)}
</For>
<div class="balance-bar-legend">
<span class="balance-bar-legend__item balance-bar-legend__item--rent">
{copy().shareRent}
</span>
<span class="balance-bar-legend__item balance-bar-legend__item--utilities">
{copy().shareUtilities}
</span>
<span class="balance-bar-legend__item balance-bar-legend__item--purchase">
{copy().shareOffset}
</span>
</div> </div>
</div> </div>
</Show> </Card>
</Card>
</> {/* ── Purchase investment donut ───────────── */}
)} <Card>
</Show> <div class="section-header">
<strong>{copy().purchaseInvestmentsTitle}</strong>
<p>{copy().purchaseInvestmentsBody}</p>
</div>
<Show
when={purchaseInvestmentChart().slices.length > 0}
fallback={<p class="empty-state">{copy().purchaseInvestmentsEmpty}</p>}
>
<div class="donut-chart">
<svg viewBox="0 0 100 100" class="donut-chart__svg">
<For each={purchaseInvestmentChart().slices}>
{(slice) => (
<circle
cx="50"
cy="50"
r="42"
fill="none"
stroke={slice.color}
stroke-width="12"
stroke-dasharray={slice.dasharray}
stroke-dashoffset={slice.dashoffset}
class="donut-chart__slice"
/>
)}
</For>
<text x="50" y="48" text-anchor="middle" class="donut-chart__total">
{purchaseInvestmentChart().totalMajor}
</text>
<text x="50" y="58" text-anchor="middle" class="donut-chart__label">
{data().currency}
</text>
</svg>
<div class="donut-chart__legend">
<For each={purchaseInvestmentChart().slices}>
{(slice) => (
<div class="donut-chart__legend-item">
<span class="donut-chart__color" style={{ background: slice.color }} />
<span>{slice.label}</span>
<strong>
{slice.amountMajor} ({slice.percentage}%)
</strong>
</div>
)}
</For>
</div>
</div>
</Show>
</Card>
</>
)}
</Match>
</Switch>
</div> </div>
) )
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { Show, For, Index, createSignal, createMemo } from 'solid-js' import { Show, For, Index, createSignal, createMemo, Switch, Match } from 'solid-js'
import { produce } from 'solid-js/store' import { produce } from 'solid-js/store'
import { Plus } from 'lucide-solid' import { Plus } from 'lucide-solid'
@@ -13,6 +13,7 @@ import { Select } from '../components/ui/select'
import { Field } from '../components/ui/field' import { Field } from '../components/ui/field'
import { Collapsible } from '../components/ui/collapsible' import { Collapsible } from '../components/ui/collapsible'
import { Toggle } from '../components/ui/toggle' import { Toggle } from '../components/ui/toggle'
import { Skeleton } from '../components/ui/skeleton'
import { import {
ledgerPrimaryAmount, ledgerPrimaryAmount,
ledgerSecondaryAmount, ledgerSecondaryAmount,
@@ -203,7 +204,7 @@ function ParticipantSplitInputs(props: ParticipantSplitInputsProps) {
export default function LedgerRoute() { export default function LedgerRoute() {
const { initData, refreshHouseholdData } = useSession() const { initData, refreshHouseholdData } = useSession()
const { copy } = useI18n() const { copy } = useI18n()
const { dashboard, effectiveIsAdmin, purchaseLedger, utilityLedger, paymentLedger } = const { dashboard, loading, effectiveIsAdmin, purchaseLedger, utilityLedger, paymentLedger } =
useDashboard() useDashboard()
// ── Purchase editor ────────────────────────────── // ── Purchase editor ──────────────────────────────
@@ -542,161 +543,177 @@ export default function LedgerRoute() {
return ( return (
<div class="route route--ledger"> <div class="route route--ledger">
<Show <Switch>
when={dashboard()} <Match when={loading()}>
fallback={ <Card>
<Skeleton style={{ width: '100%', height: '24px', 'margin-bottom': '12px' }} />
<Skeleton style={{ width: '80%', height: '48px' }} />
</Card>
<Card>
<Skeleton style={{ width: '100%', height: '24px', 'margin-bottom': '12px' }} />
<Skeleton style={{ width: '80%', height: '48px' }} />
</Card>
</Match>
<Match when={!dashboard()}>
<Card> <Card>
<p class="empty-state">{copy().ledgerEmpty}</p> <p class="empty-state">{copy().ledgerEmpty}</p>
</Card> </Card>
} </Match>
>
{(_data) => (
<>
{/* ── Purchases ──────────────────────────── */}
<Collapsible title={copy().purchasesTitle} body={copy().purchaseReviewBody} defaultOpen>
<Show when={effectiveIsAdmin()}>
<div class="ledger-actions">
<Button
variant="primary"
size="sm"
onClick={() => {
const members = dashboard()?.members ?? []
const currency = (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'
setNewPurchase({
description: '',
amountMajor: '',
currency,
splitMode: 'equal',
splitInputMode: 'equal',
participants: members.map((m) => ({
memberId: m.memberId,
included: true,
shareAmountMajor: '',
sharePercentage: ''
}))
})
setAddPurchaseOpen(true)
}}
>
<Plus size={14} />
{copy().purchaseSaveAction}
</Button>
</div>
</Show>
<Show
when={purchaseLedger().length > 0}
fallback={<p class="empty-state">{copy().purchasesEmpty}</p>}
>
<div class="ledger-list">
<For each={purchaseLedger()}>
{(entry) => (
<button
class="ledger-entry"
onClick={() => effectiveIsAdmin() && openPurchaseEditor(entry)}
disabled={!effectiveIsAdmin()}
>
<div class="ledger-entry__main">
<span class="ledger-entry__title">{entry.title}</span>
<span class="ledger-entry__actor">{entry.actorDisplayName}</span>
</div>
<div class="ledger-entry__amounts">
<strong>{ledgerPrimaryAmount(entry)}</strong>
<Show when={ledgerSecondaryAmount(entry)}>
{(secondary) => (
<span class="ledger-entry__secondary">{secondary()}</span>
)}
</Show>
</div>
</button>
)}
</For>
</div>
</Show>
</Collapsible>
{/* ── Utility bills ──────────────────────── */} <Match when={dashboard()}>
<Collapsible title={copy().utilityLedgerTitle}> {(_data) => (
<Show when={effectiveIsAdmin()}> <>
<div class="ledger-actions"> {/* ── Purchases ──────────────────────────── */}
<Button variant="primary" size="sm" onClick={() => setAddUtilityOpen(true)}> <Collapsible
<Plus size={14} /> title={copy().purchasesTitle}
{copy().addUtilityBillAction} body={copy().purchaseReviewBody}
</Button> defaultOpen
</div>
</Show>
<Show
when={utilityLedger().length > 0}
fallback={<p class="empty-state">{copy().utilityLedgerEmpty}</p>}
> >
<div class="ledger-list"> <Show when={effectiveIsAdmin()}>
<For each={utilityLedger()}> <div class="ledger-actions">
{(entry) => ( <Button
<button variant="primary"
class="ledger-entry" size="sm"
onClick={() => effectiveIsAdmin() && openUtilityEditor(entry)} onClick={() => {
disabled={!effectiveIsAdmin()} const members = dashboard()?.members ?? []
> const currency = (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'
<div class="ledger-entry__main"> setNewPurchase({
<span class="ledger-entry__title">{entry.title}</span> description: '',
<span class="ledger-entry__actor">{entry.actorDisplayName}</span> amountMajor: '',
</div> currency,
<div class="ledger-entry__amounts"> splitMode: 'equal',
<strong>{ledgerPrimaryAmount(entry)}</strong> splitInputMode: 'equal',
</div> participants: members.map((m) => ({
</button> memberId: m.memberId,
)} included: true,
</For> shareAmountMajor: '',
</div> sharePercentage: ''
</Show> }))
</Collapsible> })
setAddPurchaseOpen(true)
}}
>
<Plus size={14} />
{copy().purchaseSaveAction}
</Button>
</div>
</Show>
<Show
when={purchaseLedger().length > 0}
fallback={<p class="empty-state">{copy().purchasesEmpty}</p>}
>
<div class="ledger-list">
<For each={purchaseLedger()}>
{(entry) => (
<button
class="ledger-entry"
onClick={() => effectiveIsAdmin() && openPurchaseEditor(entry)}
disabled={!effectiveIsAdmin()}
>
<div class="ledger-entry__main">
<span class="ledger-entry__title">{entry.title}</span>
<span class="ledger-entry__actor">{entry.actorDisplayName}</span>
</div>
<div class="ledger-entry__amounts">
<strong>{ledgerPrimaryAmount(entry)}</strong>
<Show when={ledgerSecondaryAmount(entry)}>
{(secondary) => (
<span class="ledger-entry__secondary">{secondary()}</span>
)}
</Show>
</div>
</button>
)}
</For>
</div>
</Show>
</Collapsible>
{/* ── Payments ───────────────────────────── */} {/* ── Utility bills ──────────────────────── */}
<Collapsible <Collapsible title={copy().utilityLedgerTitle}>
title={copy().paymentsTitle} <Show when={effectiveIsAdmin()}>
{...(effectiveIsAdmin() && copy().paymentsAdminBody <div class="ledger-actions">
? { body: copy().paymentsAdminBody } <Button variant="primary" size="sm" onClick={() => setAddUtilityOpen(true)}>
: {})} <Plus size={14} />
> {copy().addUtilityBillAction}
<Show when={effectiveIsAdmin()}> </Button>
<div class="ledger-actions"> </div>
<Button variant="primary" size="sm" onClick={() => setAddPaymentOpen(true)}> </Show>
<Plus size={14} /> <Show
{copy().paymentsAddAction} when={utilityLedger().length > 0}
</Button> fallback={<p class="empty-state">{copy().utilityLedgerEmpty}</p>}
</div> >
</Show> <div class="ledger-list">
<Show <For each={utilityLedger()}>
when={paymentLedger().length > 0} {(entry) => (
fallback={<p class="empty-state">{copy().paymentsEmpty}</p>} <button
class="ledger-entry"
onClick={() => effectiveIsAdmin() && openUtilityEditor(entry)}
disabled={!effectiveIsAdmin()}
>
<div class="ledger-entry__main">
<span class="ledger-entry__title">{entry.title}</span>
<span class="ledger-entry__actor">{entry.actorDisplayName}</span>
</div>
<div class="ledger-entry__amounts">
<strong>{ledgerPrimaryAmount(entry)}</strong>
</div>
</button>
)}
</For>
</div>
</Show>
</Collapsible>
{/* ── Payments ───────────────────────────── */}
<Collapsible
title={copy().paymentsTitle}
{...(effectiveIsAdmin() && copy().paymentsAdminBody
? { body: copy().paymentsAdminBody }
: {})}
> >
<div class="ledger-list"> <Show when={effectiveIsAdmin()}>
<For each={paymentLedger()}> <div class="ledger-actions">
{(entry) => ( <Button variant="primary" size="sm" onClick={() => setAddPaymentOpen(true)}>
<button <Plus size={14} />
class="ledger-entry" {copy().paymentsAddAction}
onClick={() => effectiveIsAdmin() && openPaymentEditor(entry)} </Button>
disabled={!effectiveIsAdmin()} </div>
> </Show>
<div class="ledger-entry__main"> <Show
<span class="ledger-entry__title"> when={paymentLedger().length > 0}
{entry.paymentKind === 'rent' fallback={<p class="empty-state">{copy().paymentsEmpty}</p>}
? copy().paymentLedgerRent >
: copy().paymentLedgerUtilities} <div class="ledger-list">
</span> <For each={paymentLedger()}>
<span class="ledger-entry__actor">{entry.actorDisplayName}</span> {(entry) => (
</div> <button
<div class="ledger-entry__amounts"> class="ledger-entry"
<strong>{ledgerPrimaryAmount(entry)}</strong> onClick={() => effectiveIsAdmin() && openPaymentEditor(entry)}
</div> disabled={!effectiveIsAdmin()}
</button> >
)} <div class="ledger-entry__main">
</For> <span class="ledger-entry__title">
</div> {entry.paymentKind === 'rent'
</Show> ? copy().paymentLedgerRent
</Collapsible> : copy().paymentLedgerUtilities}
</> </span>
)} <span class="ledger-entry__actor">{entry.actorDisplayName}</span>
</Show> </div>
<div class="ledger-entry__amounts">
<strong>{ledgerPrimaryAmount(entry)}</strong>
</div>
</button>
)}
</For>
</div>
</Show>
</Collapsible>
</>
)}
</Match>
</Switch>
{/* ──────── Add Purchase Modal (Bug #4 fix) ──── */} {/* ──────── Add Purchase Modal (Bug #4 fix) ──── */}
<Modal <Modal