diff --git a/.env.example b/.env.example
index 568bcdb6..ae14f9ad 100644
--- a/.env.example
+++ b/.env.example
@@ -50,6 +50,11 @@ GOOGLE_MAIL_CONNECT_COMPANY_IDS=
# NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=false
# SESSION_TIMEOUT_SECRET=
+# One-off system notice ("high load right now") shown once per browser to
+# every signed-in user until this ISO timestamp (include the UTC offset).
+# Unset or past: no banner. A new timestamp shows the banner once again.
+# NEXT_PUBLIC_SYSTEM_NOTICE_UNTIL=2026-09-10T23:00:00+02:00
+
# Self-hosted only: set to true when public signup is turned off in your
# GoTrue/Supabase auth config (GOTRUE_DISABLE_SIGNUP / "Allow new users to
# sign up" off). GoTrue offers no clean server-side read of that setting, so
diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx
index 1d5e352b..e1d68421 100644
--- a/app/(dashboard)/layout.tsx
+++ b/app/(dashboard)/layout.tsx
@@ -11,6 +11,8 @@ import LazyCommandPalette from '@/components/common/LazyCommandPalette'
import { SettingsHotkey } from '@/components/settings/SettingsHotkey'
import { SessionTimeoutController } from '@/components/auth/SessionTimeoutController'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
+import { SystemNoticeBanner } from '@/components/dashboard/SystemNoticeBanner'
+import { parseSystemNoticeUntil } from '@/components/dashboard/system-notice'
import TrialExpiredDialog from '@/components/billing/TrialExpiredDialog'
import MultiUserGraceBanner from '@/components/billing/MultiUserGraceBanner'
import { resolveDormantCompanyIds } from '@/lib/company/active-company'
@@ -124,6 +126,14 @@ export default async function DashboardLayout({
pathname.startsWith(p)
)
+ // Operator-set system notice (NEXT_PUBLIC_SYSTEM_NOTICE_UNTIL): null when
+ // unset or expired, so the banner is not even rendered outside its window.
+ // Computed before the shell branches below so every signed-in user sees it,
+ // byrå consultants and stale-cookie sessions included.
+ const systemNoticeUntil = parseSystemNoticeUntil(process.env.NEXT_PUBLIC_SYSTEM_NOTICE_UNTIL)
+ const systemNoticeBanner =
+ systemNoticeUntil !== null ? : null
+
// Team now carries `kind` directly (types/index.ts, WL-08).
const membershipRows = teamMemberships
const byraMembership = membershipRows.find((m) => m.teams?.kind === 'byra') ?? null
@@ -214,6 +224,7 @@ export default async function DashboardLayout({
+ )
+}
diff --git a/components/dashboard/__tests__/system-notice.test.ts b/components/dashboard/__tests__/system-notice.test.ts
new file mode 100644
index 00000000..e309c88f
--- /dev/null
+++ b/components/dashboard/__tests__/system-notice.test.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect } from 'vitest'
+import {
+ SYSTEM_NOTICE_STORAGE_KEY,
+ dismissSystemNotice,
+ isSystemNoticeDismissed,
+ parseSystemNoticeUntil,
+} from '../system-notice'
+
+const NOW = Date.parse('2026-09-10T12:00:00+02:00')
+
+function memoryStorage(initial: Record = {}) {
+ const map = new Map(Object.entries(initial))
+ return {
+ getItem: (k: string) => map.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ map.set(k, v)
+ },
+ map,
+ }
+}
+
+describe('parseSystemNoticeUntil', () => {
+ it('returns the deadline while it is in the future', () => {
+ expect(parseSystemNoticeUntil('2026-09-10T23:00:00+02:00', NOW)).toBe(
+ Date.parse('2026-09-10T23:00:00+02:00'),
+ )
+ expect(parseSystemNoticeUntil(' 2026-09-10T23:00:00+02:00 ', NOW)).not.toBeNull()
+ })
+
+ it('returns null once the deadline has passed, or at the exact instant', () => {
+ expect(parseSystemNoticeUntil('2026-09-10T11:00:00+02:00', NOW)).toBeNull()
+ expect(parseSystemNoticeUntil('2026-09-10T12:00:00+02:00', NOW)).toBeNull()
+ })
+
+ it('accepts Z and compact offsets, rejects a date-time without any offset', () => {
+ expect(parseSystemNoticeUntil('2026-09-10T21:00:00Z', NOW)).toBe(
+ Date.parse('2026-09-10T23:00:00+02:00'),
+ )
+ expect(parseSystemNoticeUntil('2026-09-10T23:00:00+0200', NOW)).toBe(
+ Date.parse('2026-09-10T23:00:00+02:00'),
+ )
+ // Local-time parse would differ between Vercel (UTC) and a laptop.
+ expect(parseSystemNoticeUntil('2026-09-10T23:00:00', NOW)).toBeNull()
+ expect(parseSystemNoticeUntil('2026-09-10', NOW)).toBeNull()
+ })
+
+ it('returns null for unset, blank, or unparseable values', () => {
+ expect(parseSystemNoticeUntil(undefined, NOW)).toBeNull()
+ expect(parseSystemNoticeUntil(null, NOW)).toBeNull()
+ expect(parseSystemNoticeUntil('', NOW)).toBeNull()
+ expect(parseSystemNoticeUntil(' ', NOW)).toBeNull()
+ expect(parseSystemNoticeUntil('tonight', NOW)).toBeNull()
+ })
+})
+
+describe('dismissal', () => {
+ const until = Date.parse('2026-09-10T23:00:00+02:00')
+
+ it('is not dismissed until the user closes it, then stays closed for that deadline', () => {
+ const storage = memoryStorage()
+ expect(isSystemNoticeDismissed(storage, until)).toBe(false)
+ dismissSystemNotice(storage, until)
+ expect(storage.map.get(SYSTEM_NOTICE_STORAGE_KEY)).toBe(String(until))
+ expect(isSystemNoticeDismissed(storage, until)).toBe(true)
+ })
+
+ it('shows a later notice again: the dismissal is keyed by deadline', () => {
+ const storage = memoryStorage({ [SYSTEM_NOTICE_STORAGE_KEY]: String(until) })
+ expect(isSystemNoticeDismissed(storage, until + 86_400_000)).toBe(false)
+ })
+
+ it('treats missing or throwing storage as not dismissed', () => {
+ expect(isSystemNoticeDismissed(null, until)).toBe(false)
+ const throwing = {
+ getItem: () => {
+ throw new Error('blocked')
+ },
+ setItem: () => {
+ throw new Error('blocked')
+ },
+ }
+ expect(isSystemNoticeDismissed(throwing, until)).toBe(false)
+ expect(() => dismissSystemNotice(throwing, until)).not.toThrow()
+ })
+})
diff --git a/components/dashboard/system-notice.ts b/components/dashboard/system-notice.ts
new file mode 100644
index 00000000..8998f3e3
--- /dev/null
+++ b/components/dashboard/system-notice.ts
@@ -0,0 +1,58 @@
+/**
+ * System notice window: a one-off, operator-set banner ("high load right
+ * now") shown to every signed-in user until a fixed point in time.
+ *
+ * The switch is NEXT_PUBLIC_SYSTEM_NOTICE_UNTIL, an ISO timestamp with an
+ * explicit offset (e.g. 2026-09-10T23:00:00+02:00). The value doubles as the
+ * dismiss key: closing the banner stores the timestamp in localStorage, so a
+ * later notice with a new timestamp shows once again while the old dismissal
+ * stays inert. No DB read: the notice must survive the DB being unavailable.
+ */
+
+export const SYSTEM_NOTICE_STORAGE_KEY = 'Accounted:system-notice-dismissed'
+
+/**
+ * A date-time without Z or a numeric offset is parsed as the runtime's local
+ * time, which is UTC on Vercel and whatever the operator's laptop is locally.
+ * Require the offset so the deadline means the same instant everywhere.
+ */
+const HAS_UTC_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/i
+
+/**
+ * Parse the raw env value into an epoch ms deadline. Returns null when the
+ * value is missing, has no UTC offset, is unparseable, or is already in the
+ * past, so callers render nothing without a second check.
+ */
+export function parseSystemNoticeUntil(
+ raw: string | undefined | null,
+ now: number = Date.now(),
+): number | null {
+ const trimmed = raw?.trim()
+ if (!trimmed) return null
+ if (!HAS_UTC_OFFSET.test(trimmed)) return null
+ const until = new Date(trimmed).getTime()
+ if (!Number.isFinite(until)) return null
+ return until > now ? until : null
+}
+
+type StorageLike = Pick
+
+export function isSystemNoticeDismissed(
+ storage: StorageLike | null | undefined,
+ until: number,
+): boolean {
+ try {
+ return storage?.getItem(SYSTEM_NOTICE_STORAGE_KEY) === String(until)
+ } catch {
+ return false
+ }
+}
+
+export function dismissSystemNotice(storage: StorageLike | null | undefined, until: number): void {
+ try {
+ storage?.setItem(SYSTEM_NOTICE_STORAGE_KEY, String(until))
+ } catch {
+ // Private mode or blocked storage: the banner closes for this page
+ // load and may show again next time, which is the acceptable fallback.
+ }
+}
diff --git a/messages/en.json b/messages/en.json
index bcd1aec5..3b05cd22 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -6101,6 +6101,10 @@
"banner_affected": "Your access to {companyName} will be paused in {days, plural, =1 {1 day} other {# days}} unless the company upgrades.",
"banner_cta": "Upgrade"
},
+ "system_notice": {
+ "high_load": "The system is under high load right now. Some pages may respond slowly or fail temporarily. We are on it.",
+ "dismiss": "Close"
+ },
"paused": {
"title": "Your account is paused",
"body_single": "Your account is paused in {companyName}. Only one person can work in the company without a paid plan.",
diff --git a/messages/sv.json b/messages/sv.json
index dcf7f962..fdc47df4 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -6101,6 +6101,10 @@
"banner_affected": "Din åtkomst till {companyName} pausas om {days, plural, =1 {1 dag} other {# dagar}} om företaget inte uppgraderar.",
"banner_cta": "Uppgradera"
},
+ "system_notice": {
+ "high_load": "Just nu är det hög belastning i systemet. Vissa sidor kan svara långsamt eller tillfälligt ge fel. Vi jobbar på det.",
+ "dismiss": "Stäng"
+ },
"paused": {
"title": "Ditt konto är pausat",
"body_single": "Ditt konto är pausat i {companyName}. Endast en person kan arbeta i företaget utan betald plan.",