mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 15:54:03 +00:00
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:
@@ -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
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user