Răsfoiți Sursa

Let the update banner show itself out after six hours (#316)

Almost nobody presses the X, so the "we deployed something" strip rode
along until the next release and stopped being read. It now records when
it first appeared in this browser and acknowledges itself six hours
later: live in an open tab, or straight away if the deadline passed while
the app was closed.

Expiry does the same server-side write as a real dismissal, so the banner
does not come back on the user's other devices, and the key is versioned
so the next release gets its own six hours.
Bernt Christian Egeland 4 săptămâni în urmă
părinte
comite
b36fb5dfca

+ 115 - 0
src/__tests__/components/update-banner.test.tsx

@@ -0,0 +1,115 @@
+/**
+ * The "we deployed something" strip.
+ *
+ * The behaviour worth pinning is that it lets itself out. Almost nobody
+ * presses the X, so without the six-hour clock the notice rides along until
+ * the next release and stops being read at all. The clock is per browser, but
+ * expiry writes the version server-side exactly as a real dismissal does, so
+ * the banner does not come back on the user's other devices.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { render, screen, act } from '@testing-library/react'
+import { BannerSlotProvider } from '@/components/banner-slot'
+
+vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }))
+
+const markVersionSeen = vi.fn()
+vi.mock('@/features/users/Actions/versionActions', () => ({
+  markVersionSeen: (version: string) => markVersionSeen(version),
+}))
+
+const { UpdateBanner } = await import('@/components/update-banner')
+
+const SIX_HOURS = 6 * 60 * 60 * 1000
+
+/** The banner only renders inside the slot it competes for. */
+function show(lastSeenVersion: string | null = '1.0.0') {
+  return render(
+    <BannerSlotProvider>
+      <UpdateBanner
+        currentVersion="1.1.0"
+        lastSeenVersion={lastSeenVersion}
+        releaseNotesUrl="https://example.test/releases"
+      />
+    </BannerSlotProvider>
+  )
+}
+
+function shown() {
+  return screen.queryByText(/updated/)
+}
+
+beforeEach(() => {
+  localStorage.clear()
+  markVersionSeen.mockClear()
+  vi.useFakeTimers({ shouldAdvanceTime: true })
+})
+
+afterEach(() => {
+  vi.useRealTimers()
+})
+
+describe('the update banner', () => {
+  it('announces a version the account has not seen', () => {
+    show()
+    expect(shown()).not.toBeNull()
+  })
+
+  it('stays gone once dismissed, and records the version', () => {
+    show()
+    act(() => {
+      screen.getByRole('button').click()
+    })
+    expect(shown()).toBeNull()
+    expect(markVersionSeen).toHaveBeenCalledWith('1.1.0')
+  })
+
+  it('lets itself out six hours after it first appeared', () => {
+    show()
+    expect(shown()).not.toBeNull()
+
+    act(() => {
+      vi.advanceTimersByTime(SIX_HOURS)
+    })
+
+    expect(shown()).toBeNull()
+    expect(markVersionSeen).toHaveBeenCalledWith('1.1.0')
+  })
+
+  it('keeps the clock running across reloads rather than restarting it', () => {
+    show().unmount()
+
+    // Five hours later, in a fresh tab: one hour left, not six.
+    vi.setSystemTime(Date.now() + 5 * 60 * 60 * 1000)
+    show()
+    expect(shown()).not.toBeNull()
+
+    act(() => {
+      vi.advanceTimersByTime(60 * 60 * 1000)
+    })
+    expect(shown()).toBeNull()
+  })
+
+  it('goes straight away when the six hours passed while the app was closed', () => {
+    show().unmount()
+
+    vi.setSystemTime(Date.now() + SIX_HOURS + 1000)
+    show()
+
+    expect(shown()).toBeNull()
+    expect(markVersionSeen).toHaveBeenCalledWith('1.1.0')
+  })
+
+  it('gives the next release its own six hours', () => {
+    localStorage.setItem('update-banner-first-seen', `1.0.0|${Date.now() - SIX_HOURS - 1000}`)
+    show()
+    expect(shown()).not.toBeNull()
+  })
+
+  it('says nothing to an account seeing the app for the first time', () => {
+    show(null)
+    expect(shown()).toBeNull()
+    expect(markVersionSeen).toHaveBeenCalledWith('1.1.0')
+  })
+})

+ 59 - 6
src/components/update-banner.tsx

@@ -1,11 +1,39 @@
 'use client'
 
-import { useEffect, useState } from 'react'
+import { useCallback, useEffect, useState } from 'react'
 import { useTranslations } from 'next-intl'
 import { X } from 'lucide-react'
 import { markVersionSeen } from '@/features/users/Actions/versionActions'
 import { BANNER_PRIORITY, useBannerSlot } from './banner-slot'
 
+/**
+ * How long the banner stays up when nobody touches it. Six hours is a full
+ * working day of chances to read it; past that it has stopped being a notice
+ * and become part of the header.
+ */
+const AUTO_DISMISS_MS = 6 * 60 * 60 * 1000
+
+/** `<version>|<epoch ms>`: when this release's banner first appeared here. */
+const FIRST_SEEN_KEY = 'update-banner-first-seen'
+
+function firstSeenAt(version: string): number {
+  try {
+    const [seenVersion, at] = (localStorage.getItem(FIRST_SEEN_KEY) ?? '').split('|')
+    return seenVersion === version ? Number(at) || 0 : 0
+  } catch {
+    // localStorage unavailable; the clock restarts on this visit
+    return 0
+  }
+}
+
+function rememberFirstSeen(version: string, at: number) {
+  try {
+    localStorage.setItem(FIRST_SEEN_KEY, `${version}|${at}`)
+  } catch {
+    // localStorage unavailable; the banner then runs its six hours from now
+  }
+}
+
 /**
  * One-time "the app was updated" notice, shown when the running APP_VERSION
  * differs from the version stored on the user record. Dismissing (or opening
@@ -31,6 +59,11 @@ export function UpdateBanner({
     currentVersion !== 'development' &&
     lastSeenVersion !== currentVersion
 
+  const acknowledge = useCallback(() => {
+    setDismissed(true)
+    markVersionSeen(currentVersion)
+  }, [currentVersion])
+
   // First load ever for this account: seed silently so a brand-new user is
   // not greeted with "what's new" for a version they never used.
   useEffect(() => {
@@ -39,17 +72,37 @@ export function UpdateBanner({
     }
   }, [neverSeeded, currentVersion])
 
+  // Hardly anyone presses the X, so the banner otherwise rides along until the
+  // next release. Six hours after it first appeared it acknowledges itself.
+  // The clock lives in this browser, but the acknowledgement is the same
+  // server-side write as the X, so it clears the banner on every device.
+  useEffect(() => {
+    if (!show) return
+
+    const now = Date.now()
+    let since = firstSeenAt(currentVersion)
+    if (!since) {
+      since = now
+      rememberFirstSeen(currentVersion, now)
+    }
+
+    const remaining = since + AUTO_DISMISS_MS - now
+    if (remaining <= 0) {
+      acknowledge()
+      return
+    }
+
+    // Also covers a tab left open across the deadline.
+    const timer = setTimeout(acknowledge, remaining)
+    return () => clearTimeout(timer)
+  }, [show, currentVersion, acknowledge])
+
   // Last in the queue. Interesting, never urgent, and it waits behind an
   // outage notice rather than sitting under one.
   const mine = useBannerSlot('update', BANNER_PRIORITY.update, show)
 
   if (!show || !mine) return null
 
-  const acknowledge = () => {
-    setDismissed(true)
-    markVersionSeen(currentVersion)
-  }
-
   return (
     <div className="relative bg-amber-500 px-8 py-1.5 text-center text-xs font-medium text-amber-950">
       {t('updated', { version: currentVersion })}{' '}