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,25 +1,42 @@
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>
>
<Match when={dashboard()}>
{(data) => ( {(data) => (
<> <>
{/* ── Household balances ─────────────────── */} {/* ── Household balances ─────────────────── */}
@@ -145,7 +162,8 @@ export default function BalancesRoute() {
</Card> </Card>
</> </>
)} )}
</Show> </Match>
</Switch>
</div> </div>
) )
} }

View File

@@ -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 { Clock, ChevronDown, ChevronUp, Copy, Check, CreditCard } from 'lucide-solid'
import { useNavigate } from '@solidjs/router' import { useNavigate } from '@solidjs/router'
@@ -12,6 +12,7 @@ import { Field } from '../components/ui/field'
import { Input } from '../components/ui/input' import { Input } from '../components/ui/input'
import { Modal } from '../components/ui/dialog' import { Modal } from '../components/ui/dialog'
import { Toast } from '../components/ui/toast' import { Toast } from '../components/ui/toast'
import { Skeleton } from '../components/ui/skeleton'
import { memberRemainingClass, ledgerPrimaryAmount } from '../lib/ledger-helpers' import { memberRemainingClass, ledgerPrimaryAmount } from '../lib/ledger-helpers'
import { majorStringToMinor, minorToMajorString } from '../lib/money' import { majorStringToMinor, minorToMajorString } from '../lib/money'
import { import {
@@ -29,6 +30,7 @@ export default function HomeRoute() {
const { copy, locale } = useI18n() const { copy, locale } = useI18n()
const { const {
dashboard, dashboard,
loading,
currentMemberLine, currentMemberLine,
utilityLedger, utilityLedger,
utilityTotalMajor, utilityTotalMajor,
@@ -269,14 +271,43 @@ export default function HomeRoute() {
</div> </div>
{/* ── Dashboard stats ─────────────────────────── */} {/* ── Dashboard stats ─────────────────────────── */}
<Show <Switch>
when={dashboard()} <Match when={loading()}>
fallback={ <Card>
<div class="balance-card">
<div class="balance-card__header">
<Skeleton style={{ width: '140px', height: '20px' }} />
</div>
<div class="balance-card__amounts" style={{ 'margin-top': '16px' }}>
<Skeleton style={{ width: '100%', height: '48px' }} />
<div style={{ height: '12px' }} />
<Skeleton style={{ width: '80%', height: '24px' }} />
<div style={{ height: '8px' }} />
<Skeleton style={{ width: '60%', height: '24px' }} />
</div>
</div>
</Card>
<Card>
<div class="balance-card">
<div class="balance-card__header">
<Skeleton style={{ width: '120px', height: '20px' }} />
</div>
<div class="balance-card__amounts" style={{ 'margin-top': '16px' }}>
<Skeleton style={{ width: '70%', height: '24px' }} />
<div style={{ height: '8px' }} />
<Skeleton style={{ width: '50%', height: '24px' }} />
</div>
</div>
</Card>
</Match>
<Match when={!dashboard()}>
<Card> <Card>
<p class="empty-state">{copy().emptyDashboard}</p> <p class="empty-state">{copy().emptyDashboard}</p>
</Card> </Card>
} </Match>
>
<Match when={dashboard()}>
{(data) => ( {(data) => (
<> <>
<Show when={currentMemberLine()}> <Show when={currentMemberLine()}>
@@ -300,7 +331,10 @@ export default function HomeRoute() {
const period = () => effectivePeriod() ?? data().period const period = () => effectivePeriod() ?? data().period
const today = () => todayOverride() const today = () => todayOverride()
function upcomingDay(day: number): { dateLabel: string; daysUntil: number | null } { function upcomingDay(day: number): {
dateLabel: string
daysUntil: number | null
} {
const withinPeriodDays = daysUntilPeriodDay(period(), day, timezone(), today()) const withinPeriodDays = daysUntilPeriodDay(period(), day, timezone(), today())
if (withinPeriodDays === null) { if (withinPeriodDays === null) {
return { dateLabel: '—', daysUntil: null } return { dateLabel: '—', daysUntil: null }
@@ -315,7 +349,10 @@ export default function HomeRoute() {
const next = nextCyclePeriod(period()) const next = nextCyclePeriod(period())
if (!next) { if (!next) {
return { dateLabel: formatPeriodDay(period(), day, locale()), daysUntil: null } return {
dateLabel: formatPeriodDay(period(), day, locale()),
daysUntil: null
}
} }
return { return {
@@ -379,7 +416,9 @@ export default function HomeRoute() {
</strong> </strong>
</div> </div>
<div class="balance-card__row"> <div class="balance-card__row">
<span>{copy().dueOnLabel.replace('{date}', utilitiesDueDate())}</span> <span>
{copy().dueOnLabel.replace('{date}', utilitiesDueDate())}
</span>
{dueBadge(utilitiesDaysUntilDue())} {dueBadge(utilitiesDaysUntilDue())}
</div> </div>
<div class="balance-card__row"> <div class="balance-card__row">
@@ -512,7 +551,9 @@ export default function HomeRoute() {
<Card> <Card>
<div class="balance-card"> <div class="balance-card">
<div class="balance-card__header"> <div class="balance-card__header">
<span class="balance-card__label">{copy().homeFillUtilitiesTitle}</span> <span class="balance-card__label">
{copy().homeFillUtilitiesTitle}
</span>
</div> </div>
<p class="empty-state">{copy().homeFillUtilitiesBody}</p> <p class="empty-state">{copy().homeFillUtilitiesBody}</p>
<div class="editor-grid"> <div class="editor-grid">
@@ -862,7 +903,8 @@ export default function HomeRoute() {
</Card> </Card>
</> </>
)} )}
</Show> </Match>
</Switch>
{/* Quick Payment Modal */} {/* Quick Payment Modal */}
<Modal <Modal

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,18 +543,33 @@ 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>
>
<Match when={dashboard()}>
{(_data) => ( {(_data) => (
<> <>
{/* ── Purchases ──────────────────────────── */} {/* ── Purchases ──────────────────────────── */}
<Collapsible title={copy().purchasesTitle} body={copy().purchaseReviewBody} defaultOpen> <Collapsible
title={copy().purchasesTitle}
body={copy().purchaseReviewBody}
defaultOpen
>
<Show when={effectiveIsAdmin()}> <Show when={effectiveIsAdmin()}>
<div class="ledger-actions"> <div class="ledger-actions">
<Button <Button
@@ -696,7 +712,8 @@ export default function LedgerRoute() {
</Collapsible> </Collapsible>
</> </>
)} )}
</Show> </Match>
</Switch>
{/* ──────── Add Purchase Modal (Bug #4 fix) ──── */} {/* ──────── Add Purchase Modal (Bug #4 fix) ──── */}
<Modal <Modal