Files
supabase/apps/studio/components/layouts/AppLayout/StatusBanner.utils.ts
Charis 0daafca2ca feat(studio): status page banner (incident / maintenance / upcoming) (#51044)
## Summary

* Adds a new global status banner (`StatusBanner`) driven by the
[incident.io](<http://incident.io>) status page data, showing at most
one of: an active incident, in-progress maintenance, or upcoming
maintenance, in that priority order.
* All three types are independently dismissible (persisted to a new
localStorage key, `status-banner-dismissed-keys`); dismissal hides the
banner for items still active, and a new incident reappears even if a
related item was previously dismissed.
* Only shows to users who are actually affected (based on their
projects' regions) or when region data is incomplete (fails open), and
is bypassed entirely by the existing emergency incident override.
* Behind the existing `incidentIoStatusPage` ConfigCat flag —
`AppBannerWrapper` renders this new banner instead of the legacy
`StatusPageBanner` only when the flag is on; default behavior is
unchanged.
* This is PR 5b in a stacked series for Linear
[FE-4057](https://linear.app/supabase/issue/FE-4057) — see that issue
for full design context.

## Test plan

* New unit tests (`StatusBanner.utils.test.ts`) covering
banner-selection priority, dismissal-key handling, and copy generation
* New MSW component test (`StatusBanner.test.tsx`) covering loading
state, dismiss-and-persist, and the emergency-override path
* `pnpm --filter studio run typecheck`, `lint:ratchet`, `pnpm knip
--workspace apps/studio`, `pnpm test:prettier`, and relevant vitest
suites all pass

🤖 Generated with [Claude Code](<https://claude.com/claude-code>)

Co-Authored-By: Claude
[noreply@anthropic.com](<mailto:noreply@anthropic.com>)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added status banners for relevant incidents and scheduled maintenance,
including incident details, maintenance timing, and links to the status
page.
* Banners can be dismissed, and dismissed items stay hidden while new
incidents or maintenance updates can still appear.
* Upcoming maintenance banners appear within the relevant lead time, and
maintenance timing is shown when available.
* Emergency overrides display a warning banner without a dismiss option.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-29 18:09:47 -04:00

138 lines
4.9 KiB
TypeScript

import {
getDismissalKey,
isRelevantToUser,
type StatusItem,
type UserRegionContext,
} from '@/lib/status-page/status-page.utils'
export type IncidentItem = Extract<StatusItem, { kind: 'incident' }>
export type MaintenanceItem = Extract<StatusItem, { kind: 'maintenance' }>
export type UpcomingMaintenanceItem = Extract<StatusItem, { kind: 'upcoming_maintenance' }>
export type BannerSelection =
| { kind: 'incident'; items: Array<IncidentItem> }
| { kind: 'maintenance'; item: MaintenanceItem }
| { kind: 'upcoming_maintenance'; item: UpcomingMaintenanceItem }
const DAY_MS = 24 * 60 * 60 * 1000
/**
* True once the maintenance is close enough to start (within its lead time) and
* hasn't already ended. `startsAt`/`endsAt` can be null when the upstream data is
* incomplete; treat that as outside the window rather than guessing a date.
*/
export function isWithinUpcomingWindow(item: UpcomingMaintenanceItem, nowMs: number): boolean {
if (item.startsAt === null || item.endsAt === null) return false
const startsAtMs = new Date(item.startsAt).getTime()
const endsAtMs = new Date(item.endsAt).getTime()
if (Number.isNaN(startsAtMs) || Number.isNaN(endsAtMs)) return false
const leadMs = item.leadDays * DAY_MS
return startsAtMs - nowMs <= leadMs && endsAtMs > nowMs
}
function getUpcomingStartMs(item: UpcomingMaintenanceItem): number {
return item.startsAt !== null ? new Date(item.startsAt).getTime() : Infinity
}
function earliestBy<T>(items: ReadonlyArray<T>, getTimeMs: (item: T) => number): T {
return items.reduce((earliest, item) => (getTimeMs(item) < getTimeMs(earliest) ? item : earliest))
}
/**
* Picks the single highest-priority banner to show, or null when nothing qualifies.
* Priority: incident (all relevant, undismissed incidents together) > in-progress
* maintenance (earliest start) > upcoming maintenance (soonest start).
*/
export function selectBanner(args: {
items: Array<StatusItem>
user: UserRegionContext
dismissedKeys: ReadonlySet<string>
nowMs: number
}): BannerSelection | null {
const { items, user, dismissedKeys, nowMs } = args
const eligible = items.filter(
(item) => item.showBanner && !item.isProjectCreationOnly && isRelevantToUser(item, user)
)
const withinWindow = eligible.filter(
(item) => item.kind !== 'upcoming_maintenance' || isWithinUpcomingWindow(item, nowMs)
)
const undismissed = withinWindow.filter((item) => !dismissedKeys.has(getDismissalKey(item)))
const incidents = undismissed.filter((item): item is IncidentItem => item.kind === 'incident')
if (incidents.length > 0) return { kind: 'incident', items: incidents }
const maintenances = undismissed.filter(
(item): item is MaintenanceItem => item.kind === 'maintenance'
)
if (maintenances.length > 0) {
return {
kind: 'maintenance',
item: earliestBy(maintenances, (item) => new Date(item.startedAt).getTime()),
}
}
const upcoming = undismissed.filter(
(item): item is UpcomingMaintenanceItem => item.kind === 'upcoming_maintenance'
)
if (upcoming.length > 0) {
return { kind: 'upcoming_maintenance', item: earliestBy(upcoming, getUpcomingStartMs) }
}
return null
}
export function getKeysToDismiss(selection: BannerSelection): Array<string> {
if (selection.kind === 'incident') return selection.items.map(getDismissalKey)
return [getDismissalKey(selection.item)]
}
/**
* Drops dismissed keys for items that are no longer in the (unfiltered) current
* item list — a finished incident or maintenance can't be re-dismissed, so there's
* no reason to keep growing the list forever — then adds the newly dismissed keys.
*/
export function pruneDismissedKeys(params: {
prev: Array<string>
allItems: Array<StatusItem>
add: Array<string>
}): Array<string> {
const { prev, allItems, add } = params
const activeKeys = new Set(allItems.map(getDismissalKey))
const kept = prev.filter((key) => activeKeys.has(key))
return Array.from(new Set([...kept, ...add]))
}
export function getBannerCopy(selection: BannerSelection): { title: string; description: string } {
switch (selection.kind) {
case 'incident': {
const title =
selection.items.length > 1
? 'We are investigating multiple technical issues'
: 'We are investigating a technical issue'
return { title, description: 'Follow the status page for updates' }
}
case 'maintenance': {
const { scheduledEndAt } = selection.item
const hasFutureEnd =
scheduledEndAt !== null && new Date(scheduledEndAt).getTime() > Date.now()
return {
title: 'Scheduled maintenance in progress',
description: hasFutureEnd
? 'Some services may be temporarily unavailable until maintenance ends'
: 'Some services may be temporarily unavailable',
}
}
case 'upcoming_maintenance': {
return {
title: 'Upcoming scheduled maintenance',
description: 'Some services may be temporarily unavailable during this window',
}
}
}
}