From a8980a3a415499f423ebf2812746d61c43ca20c0 Mon Sep 17 00:00:00 2001
From: Jakob Wennberg
Date: Wed, 26 Aug 2026 14:02:09 +0200
Subject: [PATCH] perf(hem): stream the home page in three sections and trim
its query plan (#1945)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
app/(dashboard)/hem-sections.tsx | 232 ++++++++++++++++++++++
app/(dashboard)/page.tsx | 200 +++++--------------
components/dashboard/DashboardContent.tsx | 105 ++--------
components/dashboard/HemNotices.tsx | 24 +++
components/dashboard/HemSkeletons.tsx | 35 ++++
lib/notices/__tests__/aggregate.test.ts | 19 ++
lib/notices/aggregate.ts | 11 +-
lib/worklist/__tests__/aggregate.test.ts | 10 +
lib/worklist/aggregate.ts | 16 +-
lib/worklist/categories.ts | 25 ++-
10 files changed, 429 insertions(+), 248 deletions(-)
create mode 100644 app/(dashboard)/hem-sections.tsx
create mode 100644 components/dashboard/HemNotices.tsx
create mode 100644 components/dashboard/HemSkeletons.tsx
diff --git a/app/(dashboard)/hem-sections.tsx b/app/(dashboard)/hem-sections.tsx
new file mode 100644
index 00000000..3286d3e6
--- /dev/null
+++ b/app/(dashboard)/hem-sections.tsx
@@ -0,0 +1,232 @@
+import { after } from 'next/server'
+import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
+import AttGoraSection from '@/components/dashboard/AttGoraSection'
+import ResumePane from '@/components/dashboard/ResumePane'
+import { HemNotices } from '@/components/dashboard/HemNotices'
+import { getWorklistCounts, listSuggestedMatches, SUGGESTED_MATCH_SCAN_CAP } from '@/lib/worklist'
+import { listResumeItems } from '@/lib/worklist/resume'
+import { getCompanyNotices } from '@/lib/notices'
+import { expiringBankConnectionsFrom } from '@/lib/notices/categories'
+import { vatDeadlineLine } from '@/lib/onboarding/checklist'
+import type { InitialSetupState, MomsPeriod, OnboardingProgress } from '@/types'
+import { getDashboardAuthContext } from './request-context'
+
+/**
+ * Hem's streamed sections. Each is an async server component rendered behind
+ * its own in page.tsx, so the greeting shell paints as soon as the
+ * page's small first wave resolves and each block fills in when its own
+ * queries land. The Supabase client comes from the request-cached auth
+ * context, never from props (not serialisable).
+ */
+
+type BankConnectionRow = {
+ id: string
+ status: string
+ consent_expires: string | null
+ bank_name: string
+ last_sie_sweep: unknown
+}
+
+type SieSweepSummaryLite = {
+ auto_linked?: number
+ suggested?: number
+ unmatched?: number
+ errors?: number
+ ran_at?: string
+}
+
+export async function HemNoticesSection({
+ companyId,
+ userId,
+ now,
+}: {
+ companyId: string
+ userId: string
+ now: Date
+}) {
+ const { supabase } = await getDashboardAuthContext()
+ // Degraded-state notices (lib/notices): broken/expiring bank connections,
+ // Skatteverket reconnect, failing backups, the wrong-account hint (#1231),
+ // priority-ordered and dismissal-filtered. The stale-dismissal reap is
+ // hygiene and runs after the response (Next `after`), not on the read path.
+ const notices = await getCompanyNotices(supabase, companyId, {
+ userId,
+ now,
+ deferReap: (task) => after(task),
+ })
+ return
+}
+
+export async function HemChecklistSection({
+ companyId,
+ userId,
+ now,
+ initialSetup,
+ agentBuilt,
+ vatRegistered,
+ momsPeriod,
+}: {
+ companyId: string
+ userId: string
+ now: Date
+ initialSetup: InitialSetupState
+ agentBuilt: boolean
+ vatRegistered: boolean
+ momsPeriod: MomsPeriod | null
+}) {
+ const { supabase } = await getDashboardAuthContext()
+ const [
+ { count: customerCount },
+ { count: invoiceCount },
+ { count: transactionCount },
+ { data: bankConnections },
+ { count: sieImportCount },
+ { count: skatteverketTokenCount },
+ { count: inboxItemCount },
+ { data: nextVatDeadline },
+ { data: latestFileSweep },
+ ] = await Promise.all([
+ supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
+ supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
+ supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
+ supabase.from('bank_connections').select('id, status, consent_expires, bank_name, last_sie_sweep').eq('company_id', companyId).eq('status', 'active'),
+ supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'),
+ // Skatteverket connections are per (user, company): filtering on user_id
+ // alone made a connection on ANY of the user's companies hide the connect
+ // nudge on all of them.
+ supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', userId).eq('company_id', companyId),
+ // Any item ever received in the document inbox (email/WhatsApp/upload)
+ // marks the receipts checklist step done: same "has ever done X" shape
+ // as the other flags above.
+ supabase.from('invoice_inbox_items').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
+ // Next upcoming momsdeklaration for the checklist's Skatteverket step.
+ // Rows are system-generated per company settings; dismissed rows are
+ // excluded everywhere deadlines are listed, so here too.
+ supabase
+ .from('deadlines')
+ .select('due_date')
+ .eq('company_id', companyId)
+ .in('tax_deadline_type', ['moms_monthly', 'moms_quarterly', 'moms_yearly'])
+ .eq('is_completed', false)
+ .is('dismissed_at', null)
+ .gte('due_date', now.toISOString().slice(0, 10))
+ .order('due_date', { ascending: true })
+ .limit(1)
+ .maybeSingle(),
+ // Latest bank-file SIE sweep (PSD2 sync stamps bank_connections.last_sie_sweep
+ // instead). Feeds the checklist's bank step with "X matchade, Y att
+ // granska". Best-effort: absent rows just render no note.
+ supabase
+ .from('bank_file_imports')
+ .select('sie_sweep')
+ .eq('company_id', companyId)
+ .not('sie_sweep', 'is', null)
+ .order('created_at', { ascending: false })
+ .limit(1)
+ .maybeSingle(),
+ ])
+
+ const connections = (bankConnections ?? []) as BankConnectionRow[]
+ const onboardingProgress: OnboardingProgress = {
+ hasCustomers: (customerCount || 0) > 0,
+ hasInvoices: (invoiceCount || 0) > 0,
+ hasBankConnected: connections.length > 0 || (transactionCount || 0) > 0,
+ hasSIEImport: (sieImportCount || 0) > 0,
+ hasSkatteverketConnected: (skatteverketTokenCount || 0) > 0,
+ hasInboxItems: (inboxItemCount || 0) > 0,
+ }
+
+ const vatLine = vatDeadlineLine({
+ vatRegistered,
+ momsPeriod,
+ nextVatDueDate: nextVatDeadline?.due_date ?? null,
+ })
+
+ const sweepCandidates: SieSweepSummaryLite[] = [
+ ...connections
+ .map((c) => c.last_sie_sweep as SieSweepSummaryLite | null)
+ .filter((s): s is SieSweepSummaryLite => Boolean(s)),
+ ...(latestFileSweep?.sie_sweep ? [latestFileSweep.sie_sweep as SieSweepSummaryLite] : []),
+ ]
+ const sieSweep =
+ sweepCandidates.length > 0
+ ? sweepCandidates.reduce((a, b) => ((a.ran_at ?? '') >= (b.ran_at ?? '') ? a : b))
+ : null
+
+ return (
+
+ )
+}
+
+export async function HemPanesSection({
+ companyId,
+ now,
+ setupOpen,
+}: {
+ companyId: string
+ now: Date
+ setupOpen: boolean
+}) {
+ const { supabase } = await getDashboardAuthContext()
+ // Fetched once at the scan cap: the Att göra pane shows the first five and
+ // the worklist count is the list's length (it used to scan the same rows
+ // twice). Everything else runs in the same wave.
+ const suggestedMatchesPromise = listSuggestedMatches(supabase, companyId, SUGGESTED_MATCH_SCAN_CAP)
+ const [worklist, suggestedMatches, resumeItems, { data: bankConnections }, postedEntries] =
+ await Promise.all([
+ // Pending-work counts come from lib/worklist: the same source as the
+ // sidebar badges, so the numbers can never diverge.
+ getWorklistCounts(supabase, companyId, { suggestedMatches: suggestedMatchesPromise }),
+ suggestedMatchesPromise,
+ // In-progress work for the Fortsätt pane: pure draft-state derivation.
+ listResumeItems(supabase, companyId, now),
+ supabase.from('bank_connections').select('id, status, consent_expires, bank_name, last_sie_sweep').eq('company_id', companyId).eq('status', 'active'),
+ // Posted entries distinguish "brand-new empty ledger" from "all caught
+ // up" in the Att göra empty state (hits the partial posted/reversed index).
+ supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('company_id', companyId).in('status', ['posted', 'reversed']),
+ ])
+
+ // "Empty ledger" only matters while the setup checklist is still open; once
+ // it is completed or dismissed the ordinary all-clear copy applies. A failed
+ // count must NOT read as empty: that would tell a company with real
+ // bookkeeping that its ledger is blank, so errors degrade to the normal copy.
+ const emptyLedger = setupOpen && !postedEntries.error && (postedEntries.count || 0) === 0
+
+ // Same day-math as the bank_connection_expiring notice predicate
+ // (lib/notices/categories.ts): the Bevaka row and the notice can never
+ // disagree on the threshold.
+ const expiringBankConnections = expiringBankConnectionsFrom(
+ (bankConnections ?? []) as BankConnectionRow[],
+ now,
+ )
+
+ return (
+ 0 ? 'grid items-start gap-x-6 gap-y-8 md:grid-cols-2' : undefined}>
+
+
+
+ )
+}
diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx
index a9cd9288..c126b203 100644
--- a/app/(dashboard)/page.tsx
+++ b/app/(dashboard)/page.tsx
@@ -1,17 +1,14 @@
+import { Suspense } from 'react'
import { redirect } from 'next/navigation'
import DashboardContent from '@/components/dashboard/DashboardContent'
-import { getWorklistCounts, listSuggestedMatches } from '@/lib/worklist'
-import { listResumeItems } from '@/lib/worklist/resume'
-import { getCompanyNotices } from '@/lib/notices'
-import { expiringBankConnectionsFrom } from '@/lib/notices/categories'
-import { vatDeadlineLine } from '@/lib/onboarding/checklist'
-import type { OnboardingProgress } from '@/types'
+import { ChecklistSkeleton, PanesSkeleton } from '@/components/dashboard/HemSkeletons'
import {
getDashboardAuthContext,
getDashboardCompanyId,
getDashboardSettings,
getResolvedDashboardAgentProfile,
} from './request-context'
+import { HemChecklistSection, HemNoticesSection, HemPanesSection } from './hem-sections'
export const dynamic = 'force-dynamic'
@@ -20,6 +17,14 @@ export const dynamic = 'force-dynamic'
// dev_docs/last_session_resume.md §8), which also pruned their fetches:
// the journal-line YTD aggregation, unpaid-invoice totals and deadline
// queries are gone and the page got faster.
+//
+// Streaming: the page itself 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 (hem-sections.tsx),
+// so ~30 queries fill three blocks in as they land instead of holding the
+// whole page behind the slowest one. RSC streaming applies to client
+// navigations too, not only hard loads.
export default async function DashboardPage() {
const [{ supabase, user }, companyId] = await Promise.all([
@@ -37,70 +42,16 @@ export default async function DashboardPage() {
const now = new Date()
- // Fetch all data in parallel
- const [
- settingsRes,
- { count: customerCount },
- { count: invoiceCount },
- { count: transactionCount },
- { data: bankConnections },
- { count: sieImportCount },
- { count: skatteverketTokenCount },
- { count: inboxItemCount },
- { count: postedEntryCount, error: postedEntryError },
- { data: nextVatDeadline },
- { data: profile },
- agentProfile,
- worklist,
- suggestedMatches,
- resumeItems,
- notices,
- ] = await Promise.all([
- getDashboardSettings(),
- supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
- supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
- supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
- supabase.from('bank_connections').select('id, status, consent_expires, bank_name, last_sie_sweep').eq('company_id', companyId).eq('status', 'active'),
- supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'),
- // Skatteverket connections are per (user, company): filtering on user_id
- // alone made a connection on ANY of the user's companies hide the connect
- // nudge on all of them.
- supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('company_id', companyId),
- // Any item ever received in the document inbox (email/WhatsApp/upload)
- // marks the receipts checklist step done: same "has ever done X" shape
- // as the other flags above.
- supabase.from('invoice_inbox_items').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
- // Posted entries distinguish "brand-new empty ledger" from "all caught
- // up" in the Att göra empty state (hits the partial posted/reversed index).
- supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('company_id', companyId).in('status', ['posted', 'reversed']),
- // Next upcoming momsdeklaration for the checklist's Skatteverket step.
- // Rows are system-generated per company settings; dismissed rows are
- // excluded everywhere deadlines are listed, so here too.
- supabase
- .from('deadlines')
- .select('due_date')
- .eq('company_id', companyId)
- .in('tax_deadline_type', ['moms_monthly', 'moms_quarterly', 'moms_yearly'])
- .eq('is_completed', false)
- .is('dismissed_at', null)
- .gte('due_date', now.toISOString().slice(0, 10))
- .order('due_date', { ascending: true })
- .limit(1)
- .maybeSingle(),
- // First name for the greeting.
- supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(),
- getResolvedDashboardAgentProfile(),
- // Pending-work counts + suggested matches come from lib/worklist: the
- // same source as the sidebar badges, so the numbers can never diverge.
- getWorklistCounts(supabase, companyId),
- listSuggestedMatches(supabase, companyId, 5),
- // In-progress work for the Fortsätt pane: pure draft-state derivation.
- listResumeItems(supabase, companyId, now),
- // Degraded-state notices (lib/notices): broken/expiring bank
- // connections, Skatteverket reconnect, failing backups, and the
- // wrong-account hint (#1231), priority-ordered and dismissal-filtered.
- getCompanyNotices(supabase, companyId, { userId: user.id, now }),
- ])
+ const [settingsRes, { data: profile }, agentProfile, { count: skatteverketTokenCount }] =
+ await Promise.all([
+ getDashboardSettings(),
+ // First name for the greeting.
+ supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(),
+ getResolvedDashboardAgentProfile(),
+ // The Skatteverket promo below the panes needs this flag in the shell;
+ // the checklist section reads it again for its own step (cheap head count).
+ supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('company_id', companyId),
+ ])
// A FAILED settings read must not masquerade as "onboarding not done":
// that sent fully onboarded users back to the wizard on a transient query
@@ -117,94 +68,43 @@ export default async function DashboardPage() {
}
const agentBuilt = Boolean(agentProfile?.verified_at)
-
- const onboardingProgress: OnboardingProgress = {
- hasCustomers: (customerCount || 0) > 0,
- hasInvoices: (invoiceCount || 0) > 0,
- hasBankConnected: (bankConnections?.length || 0) > 0 || (transactionCount || 0) > 0,
- hasSIEImport: (sieImportCount || 0) > 0,
- hasSkatteverketConnected: (skatteverketTokenCount || 0) > 0,
- hasInboxItems: (inboxItemCount || 0) > 0,
- }
-
- const vatLine = vatDeadlineLine({
- vatRegistered: settings.vat_registered,
- momsPeriod: settings.moms_period ?? null,
- nextVatDueDate: nextVatDeadline?.due_date ?? null,
- })
-
- // "Empty ledger" only matters while the setup checklist is still open; once
- // it is completed or dismissed the ordinary all-clear copy applies. A failed
- // count must NOT read as empty: that would tell a company with real
- // bookkeeping that its ledger is blank, so errors degrade to the normal copy.
- const setupOpen = !settings.initial_setup_completed_at && !settings.initial_setup_dismissed_at
- const emptyLedger = setupOpen && !postedEntryError && (postedEntryCount || 0) === 0
-
- // Same day-math as the bank_connection_expiring notice predicate
- // (lib/notices/categories.ts): the Bevaka row and the notice can never
- // disagree on the threshold.
- const expiringBankConnections = expiringBankConnectionsFrom(bankConnections || [], now)
-
const userFirstName = profile?.full_name?.trim().split(/\s+/)[0] ?? null
-
- // Latest SIE reconciliation-sweep summary across both history sources
- // (PSD2 sync stamps bank_connections.last_sie_sweep; a bank-file import
- // stamps bank_file_imports.sie_sweep). Feeds the checklist's bank step with
- // "X matchade, Y att granska" so a migrator sees the sweep outcome without
- // hunting for it. Best-effort: absent rows just render no note.
- type SieSweepSummaryLite = {
- auto_linked?: number
- suggested?: number
- unmatched?: number
- errors?: number
- ran_at?: string
+ const initialSetup = {
+ path: settings.initial_setup_path ?? null,
+ completedAt: settings.initial_setup_completed_at ?? null,
+ dismissedAt: settings.initial_setup_dismissed_at ?? null,
}
- const { data: latestFileSweep } = await supabase
- .from('bank_file_imports')
- .select('sie_sweep')
- .eq('company_id', companyId)
- .not('sie_sweep', 'is', null)
- .order('created_at', { ascending: false })
- .limit(1)
- .maybeSingle()
- const sweepCandidates: SieSweepSummaryLite[] = [
- ...(bankConnections || [])
- .map((c) => c.last_sie_sweep as SieSweepSummaryLite | null)
- .filter((s): s is SieSweepSummaryLite => Boolean(s)),
- ...(latestFileSweep?.sie_sweep ? [latestFileSweep.sie_sweep as SieSweepSummaryLite] : []),
- ]
- const sieSweep =
- sweepCandidates.length > 0
- ? sweepCandidates.reduce((a, b) => ((a.ran_at ?? '') >= (b.ran_at ?? '') ? a : b))
- : null
+ const setupOpen = !settings.initial_setup_completed_at && !settings.initial_setup_dismissed_at
return (
0}
+ notices={
+
+
+
+ }
+ checklist={
+ }>
+
+
+ }
+ panes={
+ }>
+
+
}
/>
)
diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx
index 994c8b6c..504da3ff 100644
--- a/components/dashboard/DashboardContent.tsx
+++ b/components/dashboard/DashboardContent.tsx
@@ -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. */}
-
+ {notices}
{/* Greeting hero (concept scene 14) */}
@@ -129,16 +86,7 @@ export default function DashboardContent({
-
+ {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. */}
- 0 ? 'grid items-start gap-x-6 gap-y-8 md:grid-cols-2' : undefined
- }
- >
-
-
-
+ {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 && (
-
+
)}
)
diff --git a/components/dashboard/HemNotices.tsx b/components/dashboard/HemNotices.tsx
new file mode 100644
index 00000000..9a90c09f
--- /dev/null
+++ b/components/dashboard/HemNotices.tsx
@@ -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
+}
diff --git a/components/dashboard/HemSkeletons.tsx b/components/dashboard/HemSkeletons.tsx
new file mode 100644
index 00000000..32b1bb9b
--- /dev/null
+++ b/components/dashboard/HemSkeletons.tsx
@@ -0,0 +1,35 @@
+import { Skeleton } from '@/components/ui/skeleton'
+
+/** Suspense fallback for the setup checklist block. */
+export function ChecklistSkeleton() {
+ return (
+
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+ ))}
+
+ )
+}
+
+/** Suspense fallback for the Att göra + Fortsätt panes. */
+export function PanesSkeleton() {
+ return (
+
+ {[0, 1].map((col) => (
+
+
+ {[1, 2, 3, 4].map((i) => (
+
+
+
+
+ ))}
+
+ ))}
+
+ )
+}
diff --git a/lib/notices/__tests__/aggregate.test.ts b/lib/notices/__tests__/aggregate.test.ts
index 1668f50f..b1fb8bf6 100644
--- a/lib/notices/__tests__/aggregate.test.ts
+++ b/lib/notices/__tests__/aggregate.test.ts
@@ -141,6 +141,25 @@ describe('stale-dismissal reaping (contract in lib/notices/types.ts)', () => {
expect(queued.findCall('notice_dismissals', 'in')).toEqual(['notice_id', [BACKUP_ID]])
})
+ it('hands the reap to deferReap instead of awaiting it on the read path (Hem streams)', async () => {
+ queued.enqueue({ data: [{ notice_id: BACKUP_ID }] }) // dismissal read
+ queued.enqueue({ data: null }) // delete result, consumed only when the task runs
+ const deferred: Array<() => Promise> = []
+
+ const notices = await getCompanyNotices(qSupabase, 'company-1', {
+ userId: 'user-1',
+ deferReap: (task) => deferred.push(task),
+ })
+
+ expect(notices).toEqual([])
+ expect(deferred).toHaveLength(1)
+ expect(queued.findCalls('notice_dismissals', 'delete')).toEqual([])
+
+ await deferred[0]()
+ expect(queued.findCalls('notice_dismissals', 'delete').length).toBe(1)
+ expect(queued.findCall('notice_dismissals', 'in')).toEqual(['notice_id', [BACKUP_ID]])
+ })
+
it('resurfaces a NEW failure after the healthy spell reaped the dismissal', async () => {
// error -> dismiss: hidden while the incident persists.
detectMocks.backup.mockResolvedValue(backupNotice)
diff --git a/lib/notices/aggregate.ts b/lib/notices/aggregate.ts
index 3ba6e332..3574b6be 100644
--- a/lib/notices/aggregate.ts
+++ b/lib/notices/aggregate.ts
@@ -13,6 +13,12 @@ import {
const log = createLogger('notices')
export interface GetCompanyNoticesOptions {
+ /**
+ * Run the stale-dismissal reap after the response instead of on the read
+ * path (Hem passes Next's `after`). The reap is hygiene: awaiting it made
+ * every Hem render wait for a delete nobody sees.
+ */
+ deferReap?: (task: () => Promise) => void
/** Caller's user id: Skatteverket connections and dismissals are per user. */
userId: string
now?: Date
@@ -37,7 +43,7 @@ export interface GetCompanyNoticesOptions {
export async function getCompanyNotices(
supabase: SupabaseClient,
companyId: string,
- { userId, now = new Date() }: GetCompanyNoticesOptions,
+ { userId, now = new Date(), deferReap }: GetCompanyNoticesOptions,
): Promise {
const [candidates, dismissedIds] = await Promise.all([
Promise.all([
@@ -62,7 +68,8 @@ export async function getCompanyNotices(
const category = NOTICE_PRIORITY.find((c) => id.startsWith(`${c}:`))
return category !== undefined && !byCategory.has(category)
})
- await reapStaleDismissals(supabase, companyId, userId, staleIds)
+ if (deferReap) deferReap(() => reapStaleDismissals(supabase, companyId, userId, staleIds))
+ else await reapStaleDismissals(supabase, companyId, userId, staleIds)
return NOTICE_PRIORITY.map((category) => byCategory.get(category)).filter(
(n): n is Notice => n !== undefined && !dismissedIds.has(n.id),
diff --git a/lib/worklist/__tests__/aggregate.test.ts b/lib/worklist/__tests__/aggregate.test.ts
index dd2b0efa..ca3ce88f 100644
--- a/lib/worklist/__tests__/aggregate.test.ts
+++ b/lib/worklist/__tests__/aggregate.test.ts
@@ -37,6 +37,16 @@ describe('getWorklistCounts', () => {
})
})
+ it('takes the suggested-match count from a caller-supplied list instead of rescanning', async () => {
+ const { countSuggestedMatches } = await import('../categories')
+ const matches = [{ transactionId: 't1' }, { transactionId: 't2' }, { transactionId: 't3' }] as never[]
+ const { counts } = await getWorklistCounts(supabase, 'company-1', {
+ suggestedMatches: Promise.resolve(matches),
+ })
+ expect(counts.suggested_match).toBe(3)
+ expect(countSuggestedMatches).not.toHaveBeenCalled()
+ })
+
it('excludes suggested_match from the total (subset of book_transaction)', async () => {
const { total } = await getWorklistCounts(supabase, 'company-1')
// 4 + 6 + 1 + 3 + 5 + 1 + 2 + 1, without the 2 suggested matches.
diff --git a/lib/worklist/aggregate.ts b/lib/worklist/aggregate.ts
index dbfcf1bd..daddaa5d 100644
--- a/lib/worklist/aggregate.ts
+++ b/lib/worklist/aggregate.ts
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
+import type { SuggestedMatch } from './types'
import type { WorklistCounts } from './types'
import {
countDeadlinesNeedingAction,
@@ -22,9 +23,20 @@ import {
* fast path over transactions already counted in book_transaction, so it is
* excluded to avoid double-counting (see lib/worklist/types.ts).
*/
+export interface GetWorklistCountsOptions {
+ /**
+ * Suggested matches the caller is already fetching (Hem renders them in
+ * the Att göra pane): the count is taken from this list instead of a
+ * second scan of the same rows. A promise is accepted so it can run in
+ * parallel with the other counts.
+ */
+ suggestedMatches?: SuggestedMatch[] | Promise
+}
+
export async function getWorklistCounts(
supabase: SupabaseClient,
companyId: string,
+ options: GetWorklistCountsOptions = {},
): Promise {
const [
bookTransaction,
@@ -39,7 +51,9 @@ export async function getWorklistCounts(
] = await Promise.all([
countUnbookedTransactions(supabase, companyId),
countInboxDocuments(supabase, companyId),
- countSuggestedMatches(supabase, companyId),
+ options.suggestedMatches
+ ? Promise.resolve(options.suggestedMatches).then((m) => m.length)
+ : countSuggestedMatches(supabase, companyId),
countSupplierInvoicesAwaitingApproval(supabase, companyId),
countVerifikatMissingDocument(supabase, companyId),
countOverdueInvoices(supabase, companyId),
diff --git a/lib/worklist/categories.ts b/lib/worklist/categories.ts
index 1bf9db5b..984cf678 100644
--- a/lib/worklist/categories.ts
+++ b/lib/worklist/categories.ts
@@ -100,15 +100,22 @@ export async function countInboxDocuments(
// PostgREST serialises .in() into the GET query string: chunk the id list
// so a large inbox can't push the URL past proxy limits (HTTP 414, which
// would silently zero the badge via the error branch).
+ // The chunks are independent: one wave instead of N sequential round trips.
+ const chunks: string[][] = []
+ for (let i = 0; i < docIds.length; i += IN_CLAUSE_CHUNK) chunks.push(docIds.slice(i, i + IN_CLAUSE_CHUNK))
+ const results = await Promise.all(
+ chunks.map((ids) =>
+ supabase
+ .from('document_attachments')
+ .select('id', { count: 'exact', head: true })
+ .eq('company_id', companyId)
+ .in('id', ids)
+ .is('journal_entry_id', null)
+ .eq('is_current_version', true),
+ ),
+ )
let total = 0
- for (let i = 0; i < docIds.length; i += IN_CLAUSE_CHUNK) {
- const { count, error: docError } = await supabase
- .from('document_attachments')
- .select('id', { count: 'exact', head: true })
- .eq('company_id', companyId)
- .in('id', docIds.slice(i, i + IN_CLAUSE_CHUNK))
- .is('journal_entry_id', null)
- .eq('is_current_version', true)
+ for (const { count, error: docError } of results) {
if (docError) return logAndZero('inbox_document', companyId, docError)
total += count ?? 0
}
@@ -125,7 +132,7 @@ const SUGGESTED_MATCH_OR =
* feeds are chunked (fetchCandidatesChunked), so the URL length stays bounded
* regardless of this number.
*/
-const SUGGESTED_MATCH_SCAN_CAP = 200
+export const SUGGESTED_MATCH_SCAN_CAP = 200
/**
* Unbooked transactions with a still-actionable invoice/supplier-invoice match