perf(hem): stream the home page in three sections and trim its query plan (#1945)
Hem was one ~33-query render behind a single fallback: the greeting waited for the slowest worklist scan, and the request also paid a sequential bank_file_imports read after the batch, a second scan of the suggested matches (getWorklistCounts counted the same 200 rows the pane listed) and an awaited stale-dismissal delete on the read path. - page.tsx awaits only what the greeting shell and the redirects need (settings, profile, agent profile, the Skatteverket flag); the notice line, the setup checklist and the Att göra + Fortsätt panes are async server components behind their own Suspense (hem-sections.tsx). RSC streaming applies to client navigations too, so the greeting paints first on every visit and each block fills in as its queries land. - DashboardContent becomes the shell with three slots; HemNotices keeps the one client-side action (the wrong-account sign-out); HemSkeletons are the two fallbacks. - getWorklistCounts accepts the suggested matches the caller is already fetching (a promise, so it stays parallel); listSuggestedMatches runs once at the scan cap and the pane shows the first five. - countInboxDocuments runs its id chunks in one wave instead of N sequential round trips. - getCompanyNotices takes deferReap; Hem passes Next's after() so the stale-dismissal delete runs after the response. - bank_file_imports joins the checklist section's batch. Tests: worklist aggregate (precomputed matches skip the rescan), notices aggregate (deferReap receives the reap; the delete does not run inline and runs when the task does). Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
a3326a0296
commit
a8980a3a41
@@ -1,43 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
|
||||
import AttGoraSection from '@/components/dashboard/AttGoraSection'
|
||||
import ResumePane from '@/components/dashboard/ResumePane'
|
||||
import NoticeLines from '@/components/dashboard/NoticeLines'
|
||||
import { SkatteverketPromoCard } from '@/components/dashboard/SkatteverketPromoCard'
|
||||
import { AgentPromo } from '@/components/dashboard/AgentPromo'
|
||||
import type { InitialSetupState, OnboardingProgress } from '@/types'
|
||||
import type { Notice } from '@/lib/notices/types'
|
||||
import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
|
||||
import type { ResumeItem } from '@/lib/worklist/resume'
|
||||
import type { VatDeadlineLine } from '@/lib/onboarding/checklist'
|
||||
import type { InitialSetupState } from '@/types'
|
||||
|
||||
interface DashboardContentProps {
|
||||
companyId: string
|
||||
/** Signed-in user's first name for the greeting; null falls back to a
|
||||
* nameless greeting. */
|
||||
userFirstName?: string | null
|
||||
/** Expiring PSD2 consents (dashboard-only worklist extra). */
|
||||
expiringBankConnections?: { id: string; bank_name: string; days_left: number }[]
|
||||
/** Unified pending-work counts from lib/worklist: same source as the sidebar badges. */
|
||||
worklist: WorklistCounts
|
||||
/** High-confidence transaction↔invoice matches for inline one-click confirm. */
|
||||
suggestedMatches: SuggestedMatch[]
|
||||
/** In-progress work for the Fortsätt pane (lib/worklist/resume). */
|
||||
resumeItems: ResumeItem[]
|
||||
/**
|
||||
* Active degraded-state notices in priority order (lib/notices): broken or
|
||||
* expiring bank connections, Skatteverket reconnect, failing backups, the
|
||||
* wrong-account hint. Rendered as ONE attn line at the top with a quiet
|
||||
* "+N till" expander: never a stack of banners.
|
||||
*/
|
||||
notices?: Notice[]
|
||||
onboardingProgress?: OnboardingProgress
|
||||
initialSetup: InitialSetupState
|
||||
/**
|
||||
* False until the company has a verified agent_profile. When false the hero
|
||||
@@ -46,17 +20,17 @@ interface DashboardContentProps {
|
||||
* full-screen onboarding takeover.
|
||||
*/
|
||||
agentBuilt?: boolean
|
||||
/** Personalized VAT-deadline line for the checklist's Skatteverket step. */
|
||||
vatLine?: VatDeadlineLine
|
||||
hasSkatteverketConnected?: boolean
|
||||
/**
|
||||
* True while the setup checklist is still open and the company has zero
|
||||
* posted journal entries: Att göra's all-clear then reads as "empty, get
|
||||
* started" instead of a false "all caught up".
|
||||
* Streamed sections (server components behind Suspense in
|
||||
* app/(dashboard)/page.tsx): the notice line, the setup checklist and the
|
||||
* Att göra + Fortsätt panes. The shell renders the greeting immediately
|
||||
* and each slot fills in as its queries resolve, instead of the whole page
|
||||
* waiting for the slowest of ~30 queries.
|
||||
*/
|
||||
emptyLedger?: boolean
|
||||
/** Latest SIE reconciliation-sweep outcome, for the checklist's bank step
|
||||
* ("X matchade, Y att granska"). Null when no sweep has run. */
|
||||
sieSweep?: { auto_linked: number; suggested: number; unmatched: number; errors: number } | null
|
||||
notices: ReactNode
|
||||
checklist: ReactNode
|
||||
panes: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,29 +43,15 @@ interface DashboardContentProps {
|
||||
export default function DashboardContent({
|
||||
companyId,
|
||||
userFirstName,
|
||||
expiringBankConnections,
|
||||
worklist,
|
||||
suggestedMatches,
|
||||
resumeItems,
|
||||
notices = [],
|
||||
onboardingProgress,
|
||||
initialSetup,
|
||||
agentBuilt = true,
|
||||
vatLine = null,
|
||||
emptyLedger = false,
|
||||
sieSweep = null,
|
||||
hasSkatteverketConnected = false,
|
||||
notices,
|
||||
checklist,
|
||||
panes,
|
||||
}: DashboardContentProps) {
|
||||
const t = useTranslations('dashboard')
|
||||
const { company } = useCompany()
|
||||
const router = useRouter()
|
||||
|
||||
// Wrong-account hint action: sign out so the user can come back in with
|
||||
// their other login (email+password). Same flow as SandboxBanner.
|
||||
async function handleSwitchAccount() {
|
||||
const supabase = createClient()
|
||||
await supabase.auth.signOut()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
// Time-of-day greeting (concept: "God morgon, Jakob."). Client-side clock
|
||||
// on purpose (the user's local morning, not the server's), captured once
|
||||
@@ -113,10 +73,7 @@ export default function DashboardContent({
|
||||
participates in the same priority list instead of rendering its own
|
||||
unconditional line, and the old boxed BackupHealthBanner card lives
|
||||
on as the backup_failing category. */}
|
||||
<NoticeLines
|
||||
notices={notices}
|
||||
actionOverrides={{ other_account_hint: handleSwitchAccount }}
|
||||
/>
|
||||
{notices}
|
||||
|
||||
{/* Greeting hero (concept scene 14) */}
|
||||
<section>
|
||||
@@ -129,16 +86,7 @@ export default function DashboardContent({
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<NewUserChecklist
|
||||
initialState={initialSetup}
|
||||
hasBookkeepingImported={!!onboardingProgress?.hasSIEImport}
|
||||
hasBankConnected={!!onboardingProgress?.hasBankConnected}
|
||||
hasSkatteverketConnected={!!onboardingProgress?.hasSkatteverketConnected}
|
||||
hasInboxItems={!!onboardingProgress?.hasInboxItems}
|
||||
hasAgentBuilt={agentBuilt}
|
||||
vatLine={vatLine}
|
||||
sieSweep={sieSweep}
|
||||
/>
|
||||
{checklist}
|
||||
|
||||
{/* Build-assistant nudge: shown only until the company has a verified
|
||||
agent_profile, so existing/migrated users get a clear prompt instead
|
||||
@@ -151,28 +99,13 @@ export default function DashboardContent({
|
||||
|
||||
{/* The two panes (concept hem-grid). When nothing is in progress the
|
||||
right pane renders null and Att göra takes the full width. */}
|
||||
<div
|
||||
className={
|
||||
resumeItems.length > 0 ? 'grid items-start gap-x-6 gap-y-8 md:grid-cols-2' : undefined
|
||||
}
|
||||
>
|
||||
<AttGoraSection
|
||||
worklist={worklist}
|
||||
suggestedMatches={suggestedMatches}
|
||||
expiringBankConnections={expiringBankConnections}
|
||||
emptyLedger={emptyLedger}
|
||||
/>
|
||||
<ResumePane items={resumeItems} />
|
||||
</div>
|
||||
{panes}
|
||||
|
||||
{/* Connect-Skatteverket nudge for existing companies. Gated on
|
||||
agentBuilt so it never stacks under the build-assistant hero:
|
||||
one CTA surface at a time. */}
|
||||
{agentBuilt && (
|
||||
<SkatteverketPromoCard
|
||||
companyId={companyId}
|
||||
connected={!!onboardingProgress?.hasSkatteverketConnected}
|
||||
/>
|
||||
<SkatteverketPromoCard companyId={companyId} connected={hasSkatteverketConnected} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import NoticeLines from '@/components/dashboard/NoticeLines'
|
||||
import type { Notice } from '@/lib/notices/types'
|
||||
|
||||
/**
|
||||
* Hem's notice line with the one action that needs client state: the
|
||||
* wrong-account hint signs the user out so they can come back in with their
|
||||
* other login (same flow as SandboxBanner). Rendered by the streamed notices
|
||||
* section, so the shell above it never waits for the notice detectors.
|
||||
*/
|
||||
export function HemNotices({ notices }: { notices: Notice[] }) {
|
||||
const router = useRouter()
|
||||
|
||||
async function handleSwitchAccount() {
|
||||
const supabase = createClient()
|
||||
await supabase.auth.signOut()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return <NoticeLines notices={notices} actionOverrides={{ other_account_hint: handleSwitchAccount }} />
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
/** Suspense fallback for the setup checklist block. */
|
||||
export function ChecklistSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-border p-5" aria-busy="true">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Suspense fallback for the Att göra + Fortsätt panes. */
|
||||
export function PanesSkeleton() {
|
||||
return (
|
||||
<div className="grid items-start gap-x-6 gap-y-8 md:grid-cols-2" aria-busy="true">
|
||||
{[0, 1].map((col) => (
|
||||
<div key={col}>
|
||||
<Skeleton className="mb-3 h-4 w-24" />
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between gap-4 border-b border-border px-1 py-3 last:border-b-0">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user