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