feat(notices): one aggregated notice line instead of stacked degraded-state banners (#1733)
* feat(notices): lib/notices aggregator + single notice line on Hem
Degraded-state surfaces (broken/expiring bank connections, Skatteverket
reconnect, failing cloud backups, wrong-account hint) each hand-rolled
their own detection and stacked independently on the dashboard. This adds
lib/notices, mirroring lib/worklist, as the single owner of every health
predicate, and de-clutters the surfaces:
- lib/notices/{types,predicates,categories,aggregate}: five documented
categories with a fixed priority order; every predicate soft-fails to
null; pure decision helpers live in predicates.ts so 'use client' pages
can import them without pulling server-only modules. Broken supersedes
expiring for the same bank connection by construction (status filter).
- GET /api/notices + POST /api/notices/dismiss (withRouteContext), and a
notice_dismissals table (per company+user+notice_id, RLS user-scoped).
Notice ids embed a state discriminator, so a dismissal hides exactly
the state the user saw and a NEW failure surfaces again.
- Hem renders only the highest-priority notice as ONE AttnLine where the
boxed BackupHealthBanner card sat (banner deleted; its multi-provider
sentence logic moved into the backup_failing predicate), with a quiet
"+N till" inline expander. otherAccountHint joins the same list as the
lowest-priority category instead of an unconditional extra line.
- transactions and skattekonto keep their own AttnLine copy/CTA but source
the reconnect decision from the shared skvStatusNeedsReconnect /
skvAuthErrorNeedsReconnect predicates; Hem's Bevaka row imports the
expiring-consent day-math instead of duplicating it.
- design.md convention 6 addendum: max one global notice line + max one
page-domain attn line (locked convention: needs founder sign-off).
- i18n: new notices namespace in sv+en; moved banner/hint keys deleted.
- notice_dismissals classified as archive-excluded (UI state, not
räkenskapsinformation) to satisfy the full-archive contract.
SkatteverketPromoCard keeps its localStorage dismiss for now; migrating it
to notice_dismissals is a follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(notices): stable dismissals with reaping, bounded ids, unnamed-bank copy
Review fixes on the notice aggregator:
- Migration renamed 20260819080000 -> 20260819190000_notice_dismissals.sql
(version collision with another in-flight PR; content unchanged).
- backup_failing dismissal stability: the id no longer embeds
last_auto_sync_at / needs_reauth_at, which the cron re-stamps while the
SAME incident persists and so resurrected a dismissed notice daily. The
id is now stable per (provider, reason), and the opposite direction is
kept correct by stale-dismissal reaping in getCompanyNotices: when a
category is currently healthy, the caller's stored dismissals for that
category (matched on the 'category:' id prefix) are best-effort deleted,
so error -> dismiss -> healthy (reaped) -> new error resurfaces. Audit of
the other ids: bank ids embed connection id + status/expiry and skv
embeds the incident's first-error/expiry timestamp (markNeedsReconsent
only fires post-connect), all stable per incident; they get the same
reaping as hygiene. Contract documented on Notice.id in types.ts.
- NULL bank_name no longer interpolates the Swedish fallback 'banken' into
the English message: a bank_broken_one_unnamed message variant (sv + en)
is selected instead of a name param.
- Bounded notice ids: folding several connections into one discriminator
now collapses to count + first 8 hex of a sha256 over the sorted parts
(node:crypto, server-only) instead of concatenating uuids; single
connection ids stay human-readable. Dismiss schema cap tightened to 200
with an updated rationale.
- Tests: persisting failure stays dismissed across two aggregations,
healthy state reaps, new failure after reap resurfaces, hint never
reaped, failed reap swallowed, 30-connection id under 200 chars and
stable across orderings, unnamed-bank variant, sorted backup id stable
across cron re-stamps.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(notices): pg-real coverage for the notice_dismissals policies
The coverage gate is right to flag the migration: every policy on this table
binds company membership AND auth.uid(), and nothing exercised it. The suite
pins the property that makes the table different from the rest of the schema:
a dismissal is personal, so a colleague in the same company keeps seeing a
notice the other member hid. It also covers the upsert re-stamp (which needs
the UPDATE policy), cross-tenant refusal, dismissing on behalf of another
user, the caller-scoped DELETE that reaping relies on, and the composite key.
Falsification-verified against a real Postgres: weakening the SELECT policy
to company-only scoping fails the colleague-isolation test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
4cf227001d
commit
9fc05c383f
@@ -1,104 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
|
||||
// Local mirror of the cloud-backup status shape: core must not import from
|
||||
// @/extensions/, so the fields we read are declared here.
|
||||
interface BackupProviderStatus {
|
||||
provider: string
|
||||
connected: boolean
|
||||
needs_reauth: boolean
|
||||
schedule: { last_auto_sync_status: 'success' | 'error' | null } | null
|
||||
}
|
||||
|
||||
interface BackupStatus {
|
||||
providers?: BackupProviderStatus[]
|
||||
// Pre-multi-provider shape, describing Google Drive alone.
|
||||
connected: boolean
|
||||
needs_reauth: boolean
|
||||
schedule: { last_auto_sync_status: 'success' | 'error' | null } | null
|
||||
}
|
||||
|
||||
/** Brand names stay untranslated; the sentence around them is localised. */
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
google_drive: 'Google Drive',
|
||||
dropbox: 'Dropbox',
|
||||
}
|
||||
|
||||
/**
|
||||
* Warning shown on the dashboard ONLY when a connected cloud backup is failing
|
||||
* (dead token or errored auto-sync). A backup that silently stops is worse
|
||||
* than none; this makes the failure visible where the user actually is.
|
||||
* Renders nothing when the extension is off, disconnected, or healthy.
|
||||
*
|
||||
* With more than one destination connected, a failure on either one surfaces:
|
||||
* a working Drive backup does not make a broken Dropbox backup acceptable.
|
||||
*/
|
||||
export default function BackupHealthBanner() {
|
||||
const t = useTranslations('extensions')
|
||||
const [status, setStatus] = useState<BackupStatus | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ENABLED_EXTENSION_IDS.has('cloud-backup')) return
|
||||
let cancelled = false
|
||||
fetch('/api/extensions/ext/cloud-backup/status')
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((body) => {
|
||||
if (!cancelled && body?.data) setStatus(body.data as BackupStatus)
|
||||
})
|
||||
.catch(() => {
|
||||
// Fail silent: the dashboard must not degrade over a status probe.
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!status) return null
|
||||
|
||||
const providers: BackupProviderStatus[] = status.providers ?? [
|
||||
{
|
||||
provider: 'google_drive',
|
||||
connected: status.connected,
|
||||
needs_reauth: status.needs_reauth,
|
||||
schedule: status.schedule,
|
||||
},
|
||||
]
|
||||
|
||||
const failing = providers.filter(
|
||||
(p) =>
|
||||
p.connected &&
|
||||
(p.needs_reauth || p.schedule?.last_auto_sync_status === 'error')
|
||||
)
|
||||
if (failing.length === 0) return null
|
||||
|
||||
// One sentence covering everything that is broken, so two dead connections
|
||||
// do not stack two banners on the dashboard.
|
||||
const names = failing
|
||||
.map((p) => PROVIDER_LABELS[p.provider] ?? p.provider)
|
||||
.join(' + ')
|
||||
const allNeedReauth = failing.every((p) => p.needs_reauth)
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-4">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="font-medium">
|
||||
{allNeedReauth
|
||||
? t('ext_cloud_backup_banner_reauth', { provider: names })
|
||||
: t('ext_cloud_backup_banner_failing', { provider: names })}
|
||||
</p>
|
||||
<Link
|
||||
href="/import#cloud-backup"
|
||||
className="mt-1 inline-block text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||
>
|
||||
{t('ext_cloud_backup_banner_action')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,15 +4,15 @@ import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
|
||||
import AttGoraSection from '@/components/dashboard/AttGoraSection'
|
||||
import ResumePane from '@/components/dashboard/ResumePane'
|
||||
import BackupHealthBanner from '@/components/dashboard/BackupHealthBanner'
|
||||
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'
|
||||
@@ -31,11 +31,12 @@ interface DashboardContentProps {
|
||||
/** In-progress work for the Fortsätt pane (lib/worklist/resume). */
|
||||
resumeItems: ResumeItem[]
|
||||
/**
|
||||
* True when this account looks bookkeeping-empty while a same-orgnr
|
||||
* company with real bookkeeping exists in another account (#1231): the
|
||||
* user probably signed in with the wrong login (stale BankID account).
|
||||
* 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.
|
||||
*/
|
||||
otherAccountHint?: boolean
|
||||
notices?: Notice[]
|
||||
onboardingProgress?: OnboardingProgress
|
||||
initialSetup: InitialSetupState
|
||||
/**
|
||||
@@ -72,7 +73,7 @@ export default function DashboardContent({
|
||||
worklist,
|
||||
suggestedMatches,
|
||||
resumeItems,
|
||||
otherAccountHint = false,
|
||||
notices = [],
|
||||
onboardingProgress,
|
||||
initialSetup,
|
||||
agentBuilt = true,
|
||||
@@ -107,7 +108,15 @@ export default function DashboardContent({
|
||||
|
||||
return (
|
||||
<div className="stagger-enter space-y-8">
|
||||
<BackupHealthBanner />
|
||||
{/* Degraded-state notices (lib/notices): one attn line, highest
|
||||
priority first, quiet "+N till" expander. The wrong-account hint
|
||||
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 }}
|
||||
/>
|
||||
|
||||
{/* Greeting hero (concept scene 14) */}
|
||||
<section>
|
||||
@@ -118,14 +127,6 @@ export default function DashboardContent({
|
||||
{dateLine}
|
||||
{company?.name ? ` · ${company.name}` : ''}
|
||||
</p>
|
||||
{otherAccountHint && (
|
||||
<AttnLine
|
||||
className="mt-3"
|
||||
action={{ label: t('other_account_hint_action'), onClick: handleSwitchAccount }}
|
||||
>
|
||||
{t('other_account_hint')}
|
||||
</AttnLine>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<NewUserChecklist
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import type { Notice, NoticeCategory } from '@/lib/notices/types'
|
||||
|
||||
interface NoticeLinesProps {
|
||||
/** Active notices in priority order (lib/notices getCompanyNotices). */
|
||||
notices: Notice[]
|
||||
/**
|
||||
* Per-category client-side action overrides: some notices need a handler
|
||||
* rather than a link (other_account_hint signs the user out).
|
||||
*/
|
||||
actionOverrides?: Partial<Record<NoticeCategory, () => void>>
|
||||
}
|
||||
|
||||
/**
|
||||
* The Hem notice slot: renders ONLY the single highest-priority active
|
||||
* notice as one AttnLine (convention 6), with a quiet "+N till" text link
|
||||
* expanding the rest inline when more are active. No boxed card, no overlay.
|
||||
*
|
||||
* Dismiss is optimistic and per user (POST /api/notices/dismiss): a failed
|
||||
* write just lets the notice reappear on the next load, which is the safe
|
||||
* direction for a health warning.
|
||||
*/
|
||||
export default function NoticeLines({ notices, actionOverrides = {} }: NoticeLinesProps) {
|
||||
const t = useTranslations('notices')
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [hiddenIds, setHiddenIds] = useState<ReadonlySet<string>>(new Set())
|
||||
|
||||
const active = notices.filter((n) => !hiddenIds.has(n.id))
|
||||
if (active.length === 0) return null
|
||||
const visible = expanded ? active : [active[0]]
|
||||
const moreCount = active.length - 1
|
||||
|
||||
function dismiss(notice: Notice) {
|
||||
setHiddenIds((prev) => new Set(prev).add(notice.id))
|
||||
// Fire-and-forget on purpose: on failure the notice simply returns on
|
||||
// the next load. Never block or error the dashboard over a dismissal.
|
||||
void fetch('/api/notices/dismiss', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notice_id: notice.id }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{visible.map((notice, index) => {
|
||||
const override = actionOverrides[notice.category]
|
||||
return (
|
||||
<AttnLine
|
||||
key={notice.id}
|
||||
action={
|
||||
override
|
||||
? { label: t(notice.actionKey), onClick: override }
|
||||
: { label: t(notice.actionKey), href: notice.actionHref }
|
||||
}
|
||||
trailing={
|
||||
<>
|
||||
{index === 0 && !expanded && moreCount > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
{t('more_count', { count: moreCount })}
|
||||
</button>
|
||||
</>
|
||||
)}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(notice)}
|
||||
className="text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
{t('dismiss')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{t(notice.messageKey, notice.messageParams)}
|
||||
</AttnLine>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,12 @@ interface AttnLineProps {
|
||||
children: React.ReactNode
|
||||
/** Optional inline action at the end of the sentence. */
|
||||
action?: { label: string; href?: string; onClick?: () => void }
|
||||
/**
|
||||
* Optional quiet controls rendered inside the sentence after the action
|
||||
* (e.g. the notice line's "+N till" expander and dismiss). Style them
|
||||
* muted; the ochre tone belongs to the sentence and its action alone.
|
||||
*/
|
||||
trailing?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -13,7 +19,7 @@ interface AttnLineProps {
|
||||
* a single 12.5px line in the attn tone with an optional embedded action
|
||||
* link. Max one per page.
|
||||
*/
|
||||
export function AttnLine({ children, action, className }: AttnLineProps) {
|
||||
export function AttnLine({ children, action, trailing, className }: AttnLineProps) {
|
||||
return (
|
||||
<p className={cn('text-[12.5px] leading-5 text-attn', className)}>
|
||||
{children}
|
||||
@@ -38,6 +44,7 @@ export function AttnLine({ children, action, className }: AttnLineProps) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{trailing}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user