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:
Jakob Wennberg
2026-08-26 14:02:09 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent a3326a0296
commit a8980a3a41
10 changed files with 429 additions and 248 deletions
+232
View File
@@ -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 <Suspense> 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 <HemNotices notices={notices} />
}
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 (
<NewUserChecklist
initialState={initialSetup}
hasBookkeepingImported={onboardingProgress.hasSIEImport}
hasBankConnected={onboardingProgress.hasBankConnected}
hasSkatteverketConnected={onboardingProgress.hasSkatteverketConnected}
hasInboxItems={onboardingProgress.hasInboxItems}
hasAgentBuilt={agentBuilt}
vatLine={vatLine}
sieSweep={
sieSweep
? {
auto_linked: sieSweep.auto_linked ?? 0,
suggested: sieSweep.suggested ?? 0,
unmatched: sieSweep.unmatched ?? 0,
errors: sieSweep.errors ?? 0,
}
: null
}
/>
)
}
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 (
<div className={resumeItems.length > 0 ? 'grid items-start gap-x-6 gap-y-8 md:grid-cols-2' : undefined}>
<AttGoraSection
worklist={worklist}
suggestedMatches={suggestedMatches.slice(0, 5)}
expiringBankConnections={expiringBankConnections}
emptyLedger={emptyLedger}
/>
<ResumePane items={resumeItems} />
</div>
)
}
+50 -150
View File
@@ -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 <Suspense> (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 (
<DashboardContent
companyId={companyId}
agentBuilt={agentBuilt}
userFirstName={userFirstName}
expiringBankConnections={expiringBankConnections}
worklist={worklist}
suggestedMatches={suggestedMatches}
resumeItems={resumeItems}
notices={notices}
onboardingProgress={onboardingProgress}
initialSetup={{
path: settings.initial_setup_path ?? null,
completedAt: settings.initial_setup_completed_at ?? null,
dismissedAt: settings.initial_setup_dismissed_at ?? null,
}}
vatLine={vatLine}
emptyLedger={emptyLedger}
sieSweep={
sieSweep
? {
auto_linked: sieSweep.auto_linked ?? 0,
suggested: sieSweep.suggested ?? 0,
unmatched: sieSweep.unmatched ?? 0,
errors: sieSweep.errors ?? 0,
}
: null
initialSetup={initialSetup}
hasSkatteverketConnected={(skatteverketTokenCount || 0) > 0}
notices={
<Suspense fallback={null}>
<HemNoticesSection companyId={companyId} userId={user.id} now={now} />
</Suspense>
}
checklist={
<Suspense fallback={<ChecklistSkeleton />}>
<HemChecklistSection
companyId={companyId}
userId={user.id}
now={now}
initialSetup={initialSetup}
agentBuilt={agentBuilt}
vatRegistered={settings.vat_registered}
momsPeriod={settings.moms_period ?? null}
/>
</Suspense>
}
panes={
<Suspense fallback={<PanesSkeleton />}>
<HemPanesSection companyId={companyId} now={now} setupOpen={setupOpen} />
</Suspense>
}
/>
)
+19 -86
View File
@@ -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>
)
+24
View File
@@ -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 }} />
}
+35
View File
@@ -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>
)
}
+19
View File
@@ -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<void>> = []
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)
+9 -2
View File
@@ -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>) => 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<Notice[]> {
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),
+10
View File
@@ -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.
+15 -1
View File
@@ -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<SuggestedMatch[]>
}
export async function getWorklistCounts(
supabase: SupabaseClient,
companyId: string,
options: GetWorklistCountsOptions = {},
): Promise<WorklistCounts> {
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),
+16 -9
View File
@@ -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