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({
+ {systemNoticeBanner}
+ {systemNoticeBanner} {isSandbox && } + {systemNoticeBanner} {graceBanner && ( { + let timer: ReturnType | undefined + const tick = () => { + if (isSystemNoticeDismissed(safeStorage(), until)) { + setVisible(false) + return + } + const msLeft = until - Date.now() + if (msLeft <= 0) { + setVisible(false) + return + } + setVisible(true) + timer = setTimeout(tick, Math.min(msLeft, MAX_TIMER_MS)) + } + tick() + return () => { + if (timer !== undefined) clearTimeout(timer) + } + }, [until]) + + if (!visible) return null + + function handleDismiss() { + dismissSystemNotice(safeStorage(), until) + setVisible(false) + } + + return ( +
+ {t('high_load')} + +
+ ) +} 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.",