feat(select): schedule livestream banners (#51006)

## Problem

The Select banners on www and Studio still promote attendance. They need
to keep serving the waitlist until the event, promote the livestream
during the requested window, and disappear after the event.

## Solution

Use a shared three-phase clock: existing promotion before 8:00am PT on 2
October, livestream promotion from 8:00am to 5:30pm PT, and no banner
afterwards. Both livestream CTAs lead to `select.supabase.com`, where
viewers can choose a stage. Separate dismissal keys let someone who
dismissed the earlier promotion see the livestream. Open pages set a
timer for the next boundary and recheck on visibility, focus, or page
restore.

| `www` |
| --- |
| <img width="736" height="136" alt="CleanShot 2026-09-29 at 16 27
38@2x"
src="https://github.com/user-attachments/assets/1281bcf0-ece4-41fc-99d2-1f9ab6e1b3f5"
/> |
| _Until Friday 8am PT_ |
| <img width="734" height="138" alt="CleanShot 2026-09-29 at 16 28
06@2x"
src="https://github.com/user-attachments/assets/05b8779a-9961-4886-ad7d-96ba31c8b4f7"
/> |
| _Friday 8am to 5:30pm PT_ |

| `studio` |
| --- |
| <img width="612" height="454" alt="CleanShot 2026-09-29 at 16 26
51@2x"
src="https://github.com/user-attachments/assets/0d9a8bbb-c636-41dc-aa76-381196c149c2"
/> |
| _Until Friday 8am PT_ |
| <img width="616" height="440" alt="CleanShot 2026-09-29 at 16 27
09@2x"
src="https://github.com/user-attachments/assets/458afdb9-6576-4699-9419-947ca5312bcd"
/> |
| _Friday 8am to 5:30pm PT_ |

## Review instructions

1. Open the [www
homepage](https://zone-www-dot-com-git-dnywh-select-2026-livestre-93dab1-supabase.vercel.app/)
and a [hosted Studio
dashboard](https://studio-staging-git-dnywh-select-2026-livestream-05b822-supabase.vercel.app/)
in separate tabs. If the Privacy Policy update card is in front in
Studio, close it (only) to reveal the Select banner.
2. In each tab's DevTools console, paste this helper:
   ```js
   window.selectRealNow = Date.now
   window.showSelectAt = (iso) => {
     Date.now = () => new Date(iso).getTime()
     window.dispatchEvent(new Event('focus'))
   }
   ```
3. Run `showSelectAt('2026-10-02T07:59:00-07:00')`: both banners keep
the existing “Apply to attend” CTA.
4. Run `showSelectAt('2026-10-02T08:00:00-07:00')`: www shows “Supabase
Select 2026” and “Watch the livestream”; Studio shows the same title,
the description “Keynote, main stage, and build stage, streamed all
day.” and “Watch livestream”. Both CTAs link to
`https://select.supabase.com/`.
5. Run `showSelectAt('2026-10-02T17:30:00-07:00')`: neither banner is
visible. Restore the clock with `Date.now = window.selectRealNow;
window.dispatchEvent(new Event('focus'))`.

The console clock override affects only the current tab and is lost on
reload.


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

## Summary by CodeRabbit

- **New Features**
- The Select 2026 banner switches from waitlist messaging to livestream
details and a “Watch livestream” link during the livestream period.
- Livestream banner dismissals are tracked separately from waitlist
banner dismissals.
- Promotion banners end at 5:30 p.m. Pacific on October 2, 2026, and
update when the promotion changes phase, including after returning to an
open tab.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Danny White authored and GitHub committed 2026-09-30 09:09:12 +10:00
1 parent 07c75e84fc
commit ed692f6905
8 files changed
+221 -57

No files matched your search

@@ -3,8 +3,9 @@ import dayjs from 'dayjs'
import { usePathname } from 'next/navigation'
import { PropsWithChildren, useEffect, useRef, useState } from 'react'
import {
SELECT_26_LIVESTREAM_STUDIO_DISMISSAL_KEY,
SELECT_26_STUDIO_DISMISSAL_KEY,
useSelect26PromotionActive,
useSelect26PromotionPhase,
} from 'ui-patterns/Banners/Select26Promotion'
import { OrganizationResourceBanner } from '../Organization/HeaderBanner'
@@ -45,16 +46,22 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => {
const [isSelect26BannerDismissed, , { isSuccess: isSelect26DismissalLoaded }] =
useLocalStorageQuery(SELECT_26_STUDIO_DISMISSAL_KEY, false)
const isSelect26PromotionActive = useSelect26PromotionActive()
const [isSelect26LivestreamDismissed, , { isSuccess: isSelect26LivestreamDismissalLoaded }] =
useLocalStorageQuery(SELECT_26_LIVESTREAM_STUDIO_DISMISSAL_KEY, false)
const select26PromotionPhase = useSelect26PromotionPhase()
useEffect(() => {
if (!isSelect26DismissalLoaded) return
const isLivestream = select26PromotionPhase === 'livestream'
const dismissalLoaded = isLivestream
? isSelect26LivestreamDismissalLoaded
: isSelect26DismissalLoaded
if (!dismissalLoaded) return
const shouldShow = shouldShowSelect26Banner({
isPlatform: IS_PLATFORM,
dismissalLoaded: isSelect26DismissalLoaded,
isActive: isSelect26PromotionActive,
isDismissed: isSelect26BannerDismissed,
dismissalLoaded,
isActive: select26PromotionPhase !== 'ended',
isDismissed: isLivestream ? isSelect26LivestreamDismissed : isSelect26BannerDismissed,
})
if (shouldShow) {
@@ -69,8 +76,10 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => {
}
}, [
isSelect26DismissalLoaded,
isSelect26PromotionActive,
isSelect26LivestreamDismissalLoaded,
select26PromotionPhase,
isSelect26BannerDismissed,
isSelect26LivestreamDismissed,
addBanner,
dismissBanner,
])
@@ -0,0 +1,60 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BannerSelect2026 } from './BannerSelect2026'
const { dismissBanner, setWaitlistDismissed, setLivestreamDismissed } = vi.hoisted(() => ({
dismissBanner: vi.fn(),
setWaitlistDismissed: vi.fn(),
setLivestreamDismissed: vi.fn(),
}))
vi.mock('../BannerStackProvider', () => ({
BANNER_ID: { SELECT_26: 'select-2026-banner' },
useBannerStack: () => ({ dismissBanner }),
}))
vi.mock('@/hooks/misc/useLocalStorage', () => ({
useLocalStorageQuery: (key: string) => [
false,
key === 'select-2026-livestream-dismissed' ? setLivestreamDismissed : setWaitlistDismissed,
],
}))
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-10-02T07:59:59.999-07:00'))
vi.clearAllMocks()
})
afterEach(() => vi.useRealTimers())
describe('BannerSelect2026', () => {
it('switches copy and dismissal key at 8am, then disappears at 5:30pm', () => {
render(<BannerSelect2026 />)
expect(screen.getByRole('link', { name: 'Apply to attend' })).toHaveAttribute(
'href',
'https://select.supabase.com/'
)
act(() => vi.advanceTimersByTime(1))
expect(screen.getByRole('link', { name: 'Watch livestream' })).toHaveAttribute(
'href',
'https://select.supabase.com/'
)
expect(screen.getByText('Supabase Select 2026')).toBeVisible()
expect(
screen.getByText('Keynote, main stage, and build stage, streamed all day.')
).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: 'Close banner' }))
expect(setLivestreamDismissed).toHaveBeenCalledWith(true)
expect(setWaitlistDismissed).not.toHaveBeenCalled()
expect(dismissBanner).toHaveBeenCalledWith('select-2026-banner')
vi.setSystemTime(new Date('2026-10-02T17:30:00-07:00'))
act(() => document.dispatchEvent(new Event('visibilitychange')))
expect(screen.queryByRole('link', { name: 'Watch livestream' })).toBeNull()
})
})
@@ -4,10 +4,14 @@ import { Button } from 'ui'
import {
SELECT_26_CTA,
SELECT_26_DESCRIPTION,
SELECT_26_LIVESTREAM_DESCRIPTION,
SELECT_26_LIVESTREAM_STUDIO_CTA,
SELECT_26_LIVESTREAM_STUDIO_DISMISSAL_KEY,
SELECT_26_STUDIO_DISMISSAL_KEY,
SELECT_26_TITLE,
SELECT_26_URL,
Select26Field,
useSelect26PromotionPhase,
} from 'ui-patterns/Banners/Select26Promotion'
import { BannerCard } from '../BannerCard'
@@ -15,14 +19,22 @@ import { BANNER_ID, useBannerStack } from '../BannerStackProvider'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
export const BannerSelect2026 = () => {
const phase = useSelect26PromotionPhase()
const { dismissBanner } = useBannerStack()
const [, setIsDismissed] = useLocalStorageQuery(SELECT_26_STUDIO_DISMISSAL_KEY, false)
const [, setIsWaitlistDismissed] = useLocalStorageQuery(SELECT_26_STUDIO_DISMISSAL_KEY, false)
const [, setIsLivestreamDismissed] = useLocalStorageQuery(
SELECT_26_LIVESTREAM_STUDIO_DISMISSAL_KEY,
false
)
const dismiss = () => {
setIsDismissed(true)
if (phase === 'livestream') setIsLivestreamDismissed(true)
else setIsWaitlistDismissed(true)
dismissBanner(BANNER_ID.SELECT_26)
}
if (phase === 'ended') return null
return (
<BannerCard
onDismiss={dismiss}
@@ -45,7 +57,7 @@ export const BannerSelect2026 = () => {
<div className="relative z-[2] flex flex-col gap-y-1 mb-2">
<p className="text-sm font-medium text-balance">{SELECT_26_TITLE}</p>
<p className="text-xs text-foreground-lighter text-balance dark:text-[#f8f3ef]/65">
{SELECT_26_DESCRIPTION}
{phase === 'livestream' ? SELECT_26_LIVESTREAM_DESCRIPTION : SELECT_26_DESCRIPTION}
</p>
</div>
<Button
@@ -55,7 +67,7 @@ export const BannerSelect2026 = () => {
iconRight={<ArrowUpRight size={14} strokeWidth={1.5} />}
>
<Link href={SELECT_26_URL} target="_blank" rel="noopener noreferrer" onClick={dismiss}>
{SELECT_26_CTA}
{phase === 'livestream' ? SELECT_26_LIVESTREAM_STUDIO_CTA : SELECT_26_CTA}
</Link>
</Button>
</div>
@@ -1,9 +1,11 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AnnouncementBanner } from './AnnouncementBanner'
import {
SELECT_26_CTA,
SELECT_26_LIVESTREAM_CTA,
SELECT_26_LIVESTREAM_WWW_DISMISSAL_KEY,
SELECT_26_MESSAGE,
SELECT_26_TITLE,
SELECT_26_URL,
@@ -69,6 +71,33 @@ describe('AnnouncementBanner', () => {
expect(screen.queryByText(SELECT_26_TITLE)).not.toBeInTheDocument()
})
it('shows the livestream even after the waitlist banner was dismissed', async () => {
window.localStorage.setItem(SELECT_26_WWW_DISMISSAL_KEY, 'hidden')
vi.setSystemTime(new Date('2026-10-02T08:00:00-07:00'))
render(<AnnouncementBanner />)
expect(
await screen.findByRole('link', { name: new RegExp(SELECT_26_LIVESTREAM_CTA) })
).toBeVisible()
expect(
screen.getByRole('link', { name: new RegExp(SELECT_26_LIVESTREAM_CTA) })
).toHaveAttribute('href', SELECT_26_URL)
fireEvent.click(screen.getByRole('button', { name: 'Dismiss announcement' }))
expect(window.localStorage.getItem(SELECT_26_LIVESTREAM_WWW_DISMISSAL_KEY)).toBe('hidden')
})
it('removes the livestream at 5:30pm', async () => {
vi.setSystemTime(new Date('2026-10-02T17:29:00-07:00'))
render(<AnnouncementBanner />)
expect(
await screen.findByRole('link', { name: new RegExp(SELECT_26_LIVESTREAM_CTA) })
).toBeVisible()
act(() => vi.advanceTimersByTime(60 * 1000))
expect(screen.queryByRole('link', { name: new RegExp(SELECT_26_LIVESTREAM_CTA) })).toBeNull()
})
it('remains dismissible on launch-week routes', async () => {
mockUsePathname.mockReturnValue('/launch-week')
render(<AnnouncementBanner />)
@@ -3,18 +3,25 @@
import { Announcement } from './Announcement'
import announcementJSON from './data.json'
import { Select26Banner } from './Select26Banner'
import { SELECT_26_WWW_DISMISSAL_KEY, useSelect26PromotionActive } from './Select26Promotion'
import {
SELECT_26_LIVESTREAM_WWW_DISMISSAL_KEY,
SELECT_26_WWW_DISMISSAL_KEY,
useSelect26PromotionPhase,
} from './Select26Promotion'
export const announcement = announcementJSON
export const AnnouncementBanner = () => {
const isActive = useSelect26PromotionActive()
const phase = useSelect26PromotionPhase()
if (!isActive) return null
if (phase === 'ended') return null
const dismissalKey =
phase === 'livestream' ? SELECT_26_LIVESTREAM_WWW_DISMISSAL_KEY : SELECT_26_WWW_DISMISSAL_KEY
return (
<Announcement show announcementKey={SELECT_26_WWW_DISMISSAL_KEY}>
<Select26Banner />
<Announcement key={dismissalKey} show announcementKey={dismissalKey}>
<Select26Banner phase={phase} />
</Announcement>
)
}
@@ -3,13 +3,20 @@
import { ArrowUpRight } from 'lucide-react'
import Link from 'next/link'
import { SELECT_26_CTA, SELECT_26_TITLE, SELECT_26_URL, Select26Field } from './Select26Promotion'
import {
SELECT_26_CTA,
SELECT_26_LIVESTREAM_CTA,
SELECT_26_TITLE,
SELECT_26_URL,
Select26Field,
type Select26PromotionPhase,
} from './Select26Promotion'
/** Three visible rows; top/bottom reach further inward than the middle row. */
const WWW_FIELD_BASE_COLS = 24
const WWW_FIELD_ROW_WIDTHS = [26, 24, 28]
export const Select26Banner = () => (
export const Select26Banner = ({ phase }: { phase: Exclude<Select26PromotionPhase, 'ended'> }) => (
<div className="relative isolate flex min-h-14 w-full items-center overflow-hidden border-b border-[#00482f]/15 bg-[#f8f3ef] px-4 py-2.5 pr-12 text-base font-medium text-[#001a10] dark:border-white/10 dark:bg-[#0b0e0d] dark:text-[#f8f3ef] sm:px-6 sm:pr-14">
<Select26Field
cols={WWW_FIELD_BASE_COLS}
@@ -26,10 +33,10 @@ export const Select26Banner = () => (
mirror
className="absolute right-0 top-1/2 -z-10 -translate-y-1/2 text-sm opacity-80 dark:opacity-70 md:text-base xl:text-[0.95rem]"
/>
<div className="relative z-10 ml-0 mr-auto flex w-fit max-w-full flex-wrap items-center justify-start gap-x-2 gap-y-1.5 rounded-md bg-[#f8f3ef] px-2.5 py-1 sm:mx-auto sm:flex-nowrap sm:justify-center sm:gap-x-2.5 sm:px-3.5 dark:bg-[#0b0e0d]">
<div className="relative z-10 ml-0 mr-auto flex w-fit max-w-full flex-wrap items-center justify-start gap-x-2 rounded-md bg-[#f8f3ef] px-2.5 py-1 sm:mx-auto sm:flex-nowrap sm:justify-center sm:gap-x-2.5 sm:px-3.5 dark:bg-[#0b0e0d]">
<p className="text-left leading-5 sm:text-center">
{SELECT_26_TITLE}
<span className="hidden sm:inline"> is coming October 2</span>
{phase === 'waitlist' && <span className="hidden sm:inline"> is coming October 2</span>}
</p>
<span aria-hidden className="text-[#00482f]/20 dark:text-white/15">
▪
@@ -40,7 +47,7 @@ export const Select26Banner = () => (
rel="noopener noreferrer"
className="group inline-flex shrink-0 items-center gap-1 text-[#00482f] underline decoration-[#00482f]/35 underline-offset-4 transition-colors hover:decoration-[#00482f] dark:text-[#94e6b7] dark:decoration-[#94e6b7]/40 dark:hover:decoration-[#94e6b7]"
>
{SELECT_26_CTA}
{phase === 'livestream' ? SELECT_26_LIVESTREAM_CTA : SELECT_26_CTA}
<ArrowUpRight className="size-4 transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5 motion-reduce:transform-none" />
</Link>
</div>
@@ -1,36 +1,48 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isSelect26PromotionActive, useSelect26PromotionActive } from './Select26Promotion'
import { getSelect26PromotionPhase, useSelect26PromotionPhase } from './Select26Promotion'
afterEach(() => vi.useRealTimers())
describe('isSelect26PromotionActive', () => {
it('is active immediately before the campaign expiry', () => {
expect(isSelect26PromotionActive(new Date('2026-10-02T23:59:59.999-07:00').getTime())).toBe(
true
)
describe('getSelect26PromotionPhase', () => {
it.each([
['2026-10-02T07:59:59.999-07:00', 'waitlist'],
['2026-10-02T08:00:00-07:00', 'livestream'],
['2026-10-02T17:29:59.999-07:00', 'livestream'],
['2026-10-02T17:30:00-07:00', 'ended'],
['2026-10-02T17:30:00.001-07:00', 'ended'],
] as const)('returns %s as %s', (time, phase) => {
expect(getSelect26PromotionPhase(new Date(time).getTime())).toBe(phase)
})
})
it('is inactive at the campaign expiry', () => {
expect(isSelect26PromotionActive(new Date('2026-10-03T00:00:00-07:00').getTime())).toBe(false)
})
describe('useSelect26PromotionPhase', () => {
it('updates an open page at both boundaries', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-10-02T07:59:59.999-07:00'))
const { result } = renderHook(() => useSelect26PromotionPhase())
expect(result.current).toBe('waitlist')
act(() => vi.advanceTimersByTime(1))
expect(result.current).toBe('livestream')
it('is inactive after the campaign expiry', () => {
expect(isSelect26PromotionActive(new Date('2026-10-03T00:00:00.001-07:00').getTime())).toBe(
false
)
act(() => vi.advanceTimersByTime(9.5 * 60 * 60 * 1000))
expect(result.current).toBe('ended')
})
it('becomes inactive when an open page reaches the expiry', () => {
it('refreshes after a background tab resumes', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-10-02T23:59:59.999-07:00'))
const { result } = renderHook(() => useSelect26PromotionActive())
expect(result.current).toBe(true)
vi.setSystemTime(new Date('2026-10-02T07:00:00-07:00'))
const { result } = renderHook(() => useSelect26PromotionPhase())
act(() => vi.advanceTimersByTime(1))
vi.setSystemTime(new Date('2026-10-02T08:30:00-07:00'))
act(() => document.dispatchEvent(new Event('visibilitychange')))
expect(result.current).toBe('livestream')
expect(result.current).toBe(false)
vi.setSystemTime(new Date('2026-10-02T17:31:00-07:00'))
act(() => document.dispatchEvent(new Event('visibilitychange')))
expect(result.current).toBe('ended')
})
})
@@ -13,10 +13,18 @@ export const SELECT_26_MESSAGE = 'Supabase Select 2026 is coming October 2'
export const SELECT_26_DESCRIPTION =
'A curated day of talks by the industry’s best builders. Join us on October 2nd in San Francisco.'
export const SELECT_26_CTA = 'Apply to attend'
export const SELECT_26_EXPIRY = '2026-10-03T00:00:00-07:00'
export const SELECT_26_LIVESTREAM_DESCRIPTION =
'Keynote, main stage, and build stage, streamed all day.'
export const SELECT_26_LIVESTREAM_CTA = 'Watch the livestream'
export const SELECT_26_LIVESTREAM_STUDIO_CTA = 'Watch livestream'
export const SELECT_26_LIVESTREAM_START = '2026-10-02T08:00:00-07:00'
export const SELECT_26_EXPIRY = '2026-10-02T17:30:00-07:00'
export const SELECT_26_WWW_DISMISSAL_KEY = 'announcement_select_26_08'
export const SELECT_26_STUDIO_DISMISSAL_KEY = 'select-2026-promotion-dismissed'
export const SELECT_26_LIVESTREAM_WWW_DISMISSAL_KEY = 'announcement_select_26_livestream'
export const SELECT_26_LIVESTREAM_STUDIO_DISMISSAL_KEY = 'select-2026-livestream-dismissed'
const SELECT_26_LIVESTREAM_START_MS = new Date(SELECT_26_LIVESTREAM_START).getTime()
const SELECT_26_EXPIRY_MS = new Date(SELECT_26_EXPIRY).getTime()
const MAX_TIMEOUT_MS = 2_147_483_647
@@ -36,27 +44,47 @@ const FRAME_INTERVAL_MS = 70
const positiveModulo = (value: number, modulo: number) => ((value % modulo) + modulo) % modulo
export const isSelect26PromotionActive = (now = Date.now()) => now < SELECT_26_EXPIRY_MS
export type Select26PromotionPhase = 'waitlist' | 'livestream' | 'ended'
export const useSelect26PromotionActive = () => {
const [isActive, setIsActive] = useState(() => isSelect26PromotionActive())
export const getSelect26PromotionPhase = (now = Date.now()): Select26PromotionPhase => {
if (now < SELECT_26_LIVESTREAM_START_MS) return 'waitlist'
if (now < SELECT_26_EXPIRY_MS) return 'livestream'
return 'ended'
}
export const useSelect26PromotionPhase = () => {
const [phase, setPhase] = useState<Select26PromotionPhase>(() => getSelect26PromotionPhase())
useEffect(() => {
if (!isActive) return
let timeoutId: ReturnType<typeof setTimeout> | undefined
const armExpiryTimer = () => {
const remainingMs = SELECT_26_EXPIRY_MS - Date.now()
if (remainingMs <= 0) {
setIsActive(false)
return
const refreshPhase = () => {
clearTimeout(timeoutId)
const now = Date.now()
const currentPhase = getSelect26PromotionPhase(now)
setPhase(currentPhase)
let nextBoundary: number | null = null
if (currentPhase === 'waitlist') nextBoundary = SELECT_26_LIVESTREAM_START_MS
if (currentPhase === 'livestream') nextBoundary = SELECT_26_EXPIRY_MS
if (nextBoundary !== null) {
timeoutId = setTimeout(refreshPhase, Math.min(nextBoundary - now, MAX_TIMEOUT_MS))
}
timeoutId = setTimeout(armExpiryTimer, Math.min(remainingMs, MAX_TIMEOUT_MS))
}
armExpiryTimer()
return () => clearTimeout(timeoutId)
}, [isActive])
const onVisibilityChange = () => {
if (!document.hidden) refreshPhase()
}
refreshPhase()
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('focus', refreshPhase)
window.addEventListener('pageshow', refreshPhase)
return () => {
clearTimeout(timeoutId)
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('focus', refreshPhase)
window.removeEventListener('pageshow', refreshPhase)
}
}, [])
return isActive
return phase
}
type FieldCell = {