Pārlūkot izejas kodu

Count What's new opens and label an entry New for three weeks (#599)

* Count how often What's new is opened and which entry links are followed

* Label an entry New or Improved for three weeks, and count older entries when the panel is reopened
Bernt Christian Egeland 6 stundas atpakaļ
vecāks
revīzija
6a9b4e94ec

+ 87 - 1
src/__tests__/features/whats-new/whats-new-panel.test.tsx

@@ -7,7 +7,7 @@
  * moment it is pressed, with a box to say what went wrong.
  * moment it is pressed, with a box to say what went wrong.
  */
  */
 
 
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { render, screen, waitFor, within } from '@testing-library/react'
 import { render, screen, waitFor, within } from '@testing-library/react'
 import userEvent from '@testing-library/user-event'
 import userEvent from '@testing-library/user-event'
 import type { WhatsNewEntry } from '@/features/whats-new/Schema/whatsNewSchema'
 import type { WhatsNewEntry } from '@/features/whats-new/Schema/whatsNewSchema'
@@ -32,6 +32,11 @@ vi.mock('@/features/whats-new/Actions/loadMoreWhatsNew', () => ({
 
 
 const toastError = vi.fn()
 const toastError = vi.fn()
 const toastPlain = vi.fn()
 const toastPlain = vi.fn()
+const track = vi.fn()
+vi.mock('@/lib/analytics', () => ({
+  track: (...args: unknown[]) => track(...args),
+}))
+
 vi.mock('sonner', () => ({
 vi.mock('sonner', () => ({
   toast: Object.assign((message: string) => toastPlain(message), {
   toast: Object.assign((message: string) => toastPlain(message), {
     error: (message: string) => toastError(message),
     error: (message: string) => toastError(message),
@@ -97,7 +102,14 @@ function show({
   )
   )
 }
 }
 
 
+afterEach(() => {
+  vi.useRealTimers()
+})
+
+// The fixtures are dated November 2026; the New and Improved labels depend on today.
 beforeEach(() => {
 beforeEach(() => {
+  vi.useFakeTimers({ toFake: ['Date'] })
+  vi.setSystemTime(new Date('2026-11-15T12:00:00.000Z'))
   vi.clearAllMocks()
   vi.clearAllMocks()
   window.localStorage.clear()
   window.localStorage.clear()
 })
 })
@@ -363,3 +375,77 @@ describe("a video on a What's new entry", () => {
     expect(screen.queryByRole('link', { name: 'Watch the video' })).not.toBeInTheDocument()
     expect(screen.queryByRole('link', { name: 'Watch the video' })).not.toBeInTheDocument()
   })
   })
 })
 })
+
+describe("What's new, counted", () => {
+  it('says the panel was opened from the tab, and how much was in it', async () => {
+    const user = userEvent.setup()
+    show()
+    await user.click(screen.getByRole('button', { name: /^What's new/ }))
+    expect(track).toHaveBeenCalledWith('whats_new:panel_open', {
+      source: 'tab',
+      entries: ENTRIES.length,
+      unread: expect.any(Number),
+    })
+  })
+
+  it('counts the older updates still in the panel when it is opened again', async () => {
+    loadMoreWhatsNew.mockResolvedValueOnce({
+      success: true,
+      data: {
+        entries: [{ ...ENTRIES[1], id: 'closures', title: 'Closed days and staff leave' }],
+        hasMore: false,
+      },
+    })
+    const user = userEvent.setup()
+    show({ hasMore: true })
+    await user.click(screen.getByRole('button', { name: /^What's new/ }))
+    await user.click(screen.getByRole('button', { name: 'Show older updates' }))
+    await screen.findByText('Closed days and staff leave')
+    await user.keyboard('{Escape}')
+    await user.click(screen.getByRole('button', { name: /^What's new/ }))
+
+    expect(track).toHaveBeenLastCalledWith('whats_new:panel_open', {
+      source: 'tab',
+      entries: ENTRIES.length + 1,
+      unread: expect.any(Number),
+    })
+  })
+
+  it('says which link on an entry was followed', async () => {
+    const user = userEvent.setup()
+    show()
+    await user.click(screen.getByRole('button', { name: /^What's new/ }))
+    const video = screen.getByRole('link', { name: 'Watch the video' })
+    video.addEventListener('click', (event) => event.preventDefault())
+    await user.click(video)
+    expect(track).toHaveBeenCalledWith('whats_new:entry_link_click', {
+      link: 'video',
+      entry_id: ENTRIES[0].id,
+    })
+  })
+})
+
+describe("the New and Improved labels in What's new", () => {
+  it('labels an entry for three weeks after it is published, and not after', async () => {
+    const user = userEvent.setup()
+    show({
+      entries: [
+        { ...ENTRIES[0], id: 'fresh', title: 'Published last week', kind: 'new' },
+        {
+          ...ENTRIES[0],
+          id: 'old',
+          title: 'Published two months ago',
+          kind: 'new',
+          publishedAt: '2026-09-15T09:00:00.000Z',
+        },
+      ],
+      unread: [],
+    })
+    await user.click(screen.getByRole('button', { name: /^What's new/ }))
+
+    const fresh = screen.getByText('Published last week').closest('li') as HTMLElement
+    const old = screen.getByText('Published two months ago').closest('li') as HTMLElement
+    expect(within(fresh).getByText('New')).toBeInTheDocument()
+    expect(within(old).queryByText('New')).not.toBeInTheDocument()
+  })
+})

+ 1 - 1
src/components/update-banner.tsx

@@ -89,7 +89,7 @@ export function UpdateBanner({
           type="button"
           type="button"
           onClick={() => {
           onClick={() => {
             acknowledge()
             acknowledge()
-            whatsNew.open()
+            whatsNew.open('update_banner')
           }}
           }}
           className="cursor-pointer underline underline-offset-2 hover:text-amber-900"
           className="cursor-pointer underline underline-offset-2 hover:text-amber-900"
         >
         >

+ 1 - 1
src/features/whats-new/Components/WhatsNewAboutRows.tsx

@@ -21,7 +21,7 @@ export function WhatsNewAboutRows() {
     <>
     <>
       <div id="about-whats-new" className="flex items-center justify-between gap-4">
       <div id="about-whats-new" className="flex items-center justify-between gap-4">
         <span className="text-sm text-muted-foreground">{t('whatsNew')}</span>
         <span className="text-sm text-muted-foreground">{t('whatsNew')}</span>
-        <Button type="button" variant="outline" size="sm" onClick={open}>
+        <Button type="button" variant="outline" size="sm" onClick={() => open('settings')}>
           {t('whatsNewOpen')}
           {t('whatsNewOpen')}
         </Button>
         </Button>
       </div>
       </div>

+ 1 - 1
src/features/whats-new/Components/WhatsNewMenuItem.tsx

@@ -17,7 +17,7 @@ export function WhatsNewMenuItem() {
   if (!available) return null
   if (!available) return null
   return (
   return (
     <>
     <>
-      <DropdownMenuItem onClick={open}>
+      <DropdownMenuItem onClick={() => open('menu')}>
         <Megaphone className="mr-2 size-4" />
         <Megaphone className="mr-2 size-4" />
         {t('title')}
         {t('title')}
         {hasUnread && (
         {hasUnread && (

+ 28 - 11
src/features/whats-new/Components/WhatsNewPanel.tsx

@@ -12,9 +12,11 @@ import {
   SheetHeader,
   SheetHeader,
   SheetTitle,
   SheetTitle,
 } from '@/components/ui/sheet'
 } from '@/components/ui/sheet'
+import { track } from '@/lib/analytics'
 import { MARKETING_URL } from '@/lib/marketing-urls'
 import { MARKETING_URL } from '@/lib/marketing-urls'
 import { safeRedirectPath } from '@/lib/safe-redirect'
 import { safeRedirectPath } from '@/lib/safe-redirect'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
+import { isFreshEntry } from '../Lib/paging'
 import type { WhatsNewEntry } from '../Schema/whatsNewSchema'
 import type { WhatsNewEntry } from '../Schema/whatsNewSchema'
 import { WhatsNewFeedback } from './WhatsNewFeedback'
 import { WhatsNewFeedback } from './WhatsNewFeedback'
 
 
@@ -113,16 +115,19 @@ function Entry({
             timeZone,
             timeZone,
           })}
           })}
         </time>
         </time>
-        <span
-          className={cn(
-            'rounded-full px-2 py-0.5 text-[10.5px] font-semibold uppercase tracking-wider',
-            entry.kind === 'new'
-              ? 'bg-primary text-primary-foreground'
-              : 'border border-primary/40 text-foreground'
-          )}
-        >
-          {entry.kind === 'new' ? t('kindNew') : t('kindImproved')}
-        </span>
+        {/* Only while it is news: three weeks on, the date says the rest. */}
+        {isFreshEntry(entry.publishedAt) && (
+          <span
+            className={cn(
+              'rounded-full px-2 py-0.5 text-[10.5px] font-semibold uppercase tracking-wider',
+              entry.kind === 'new'
+                ? 'bg-primary text-primary-foreground'
+                : 'border border-primary/40 text-foreground'
+            )}
+          >
+            {entry.kind === 'new' ? t('kindNew') : t('kindImproved')}
+          </span>
+        )}
       </div>
       </div>
       <article className="mt-2.5 overflow-hidden rounded-xl border bg-card shadow-xs">
       <article className="mt-2.5 overflow-hidden rounded-xl border bg-card shadow-xs">
         {/* The card hairline the rest of the app uses: primary, gone by the far edge. */}
         {/* The card hairline the rest of the app uses: primary, gone by the far edge. */}
@@ -151,7 +156,13 @@ function Entry({
                   size="sm"
                   size="sm"
                   className="border-primary/35 bg-primary/10 hover:bg-primary/15"
                   className="border-primary/35 bg-primary/10 hover:bg-primary/15"
                 >
                 >
-                  <Link href={cta} onClick={onNavigate}>
+                  <Link
+                    href={cta}
+                    onClick={() => {
+                      track('whats_new:entry_link_click', { link: 'button', entry_id: entry.id })
+                      onNavigate()
+                    }}
+                  >
                     {entry.ctaLabel || t('open')}
                     {entry.ctaLabel || t('open')}
                     <ArrowRight className="h-3.5 w-3.5" />
                     <ArrowRight className="h-3.5 w-3.5" />
                   </Link>
                   </Link>
@@ -160,6 +171,9 @@ function Entry({
               {video && (
               {video && (
                 <a
                 <a
                   href={video}
                   href={video}
+                  onClick={() =>
+                    track('whats_new:entry_link_click', { link: 'video', entry_id: entry.id })
+                  }
                   target="_blank"
                   target="_blank"
                   rel="noopener noreferrer"
                   rel="noopener noreferrer"
                   className="flex items-center gap-1.5 rounded text-sm font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                   className="flex items-center gap-1.5 rounded text-sm font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -171,6 +185,9 @@ function Entry({
               {guide && (
               {guide && (
                 <a
                 <a
                   href={guide}
                   href={guide}
+                  onClick={() =>
+                    track('whats_new:entry_link_click', { link: 'guide', entry_id: entry.id })
+                  }
                   target="_blank"
                   target="_blank"
                   rel="noopener noreferrer"
                   rel="noopener noreferrer"
                   className="rounded text-sm font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                   className="rounded text-sm font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"

+ 30 - 16
src/features/whats-new/Components/WhatsNewProvider.tsx

@@ -3,6 +3,7 @@
 import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
 import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
 import { useLocale, useTranslations } from 'next-intl'
 import { useLocale, useTranslations } from 'next-intl'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
+import { track } from '@/lib/analytics'
 import { loadMoreWhatsNew } from '../Actions/loadMoreWhatsNew'
 import { loadMoreWhatsNew } from '../Actions/loadMoreWhatsNew'
 import { markWhatsNewSeen } from '../Actions/markWhatsNewSeen'
 import { markWhatsNewSeen } from '../Actions/markWhatsNewSeen'
 import { rememberWhatsNewTabHidden } from '../Lib/tabPreference'
 import { rememberWhatsNewTabHidden } from '../Lib/tabPreference'
@@ -10,11 +11,14 @@ import type { WhatsNewEntry } from '../Schema/whatsNewSchema'
 import { WhatsNewPanel } from './WhatsNewPanel'
 import { WhatsNewPanel } from './WhatsNewPanel'
 import { WhatsNewTab } from './WhatsNewTab'
 import { WhatsNewTab } from './WhatsNewTab'
 
 
+/** Where the panel was opened from, for the analytics event. */
+export type WhatsNewSource = 'tab' | 'menu' | 'settings' | 'update_banner'
+
 const Context = createContext<{
 const Context = createContext<{
   /** False when there is nothing to show: the feed is empty, hidden or unreachable. */
   /** False when there is nothing to show: the feed is empty, hidden or unreachable. */
   available: boolean
   available: boolean
   hasUnread: boolean
   hasUnread: boolean
-  open: () => void
+  open: (source: WhatsNewSource) => void
   /** Whether this browser has sent the tab on the edge of the screen away. */
   /** Whether this browser has sent the tab on the edge of the screen away. */
   tabHidden: boolean
   tabHidden: boolean
   setTabHidden: (hidden: boolean) => void
   setTabHidden: (hidden: boolean) => void
@@ -73,6 +77,7 @@ export function WhatsNewProvider({
   const [tabHidden, setTabHiddenState] = useState(initialTabHidden)
   const [tabHidden, setTabHiddenState] = useState(initialTabHidden)
   const setTabHidden = useCallback((hidden: boolean) => {
   const setTabHidden = useCallback((hidden: boolean) => {
     setTabHiddenState(hidden)
     setTabHiddenState(hidden)
+    track('whats_new:tab_toggle', { hidden })
     rememberWhatsNewTabHidden(hidden)
     rememberWhatsNewTabHidden(hidden)
   }, [])
   }, [])
   // What has been acknowledged in this tab. The server's list catches up on
   // What has been acknowledged in this tab. The server's list catches up on
@@ -138,21 +143,30 @@ export function WhatsNewProvider({
     [unreadIds, acknowledged]
     [unreadIds, acknowledged]
   )
   )
 
 
-  const open = useCallback(() => {
-    setIsOpen(true)
-    if (pending.length === 0) return
-    setMarked(new Set(pending))
-    setAcknowledged((current) => new Set([...current, ...pending]))
-    // Not recorded: let the dot come back, so the next opening tries again
-    // rather than this tab believing something the server never heard.
-    const forget = () =>
-      setAcknowledged((current) => new Set([...current].filter((id) => !pending.includes(id))))
-    markWhatsNewSeen()
-      .then((result) => {
-        if (!result.success) forget()
+  const open = useCallback(
+    (source: WhatsNewSource) => {
+      setIsOpen(true)
+      track('whats_new:panel_open', {
+        source,
+        // What the panel holds as it opens, older pages loaded earlier included.
+        entries: entries.length + older.length,
+        unread: pending.length,
       })
       })
-      .catch(forget)
-  }, [pending])
+      if (pending.length === 0) return
+      setMarked(new Set(pending))
+      setAcknowledged((current) => new Set([...current, ...pending]))
+      // Not recorded: let the dot come back, so the next opening tries again
+      // rather than this tab believing something the server never heard.
+      const forget = () =>
+        setAcknowledged((current) => new Set([...current].filter((id) => !pending.includes(id))))
+      markWhatsNewSeen()
+        .then((result) => {
+          if (!result.success) forget()
+        })
+        .catch(forget)
+    },
+    [pending, entries.length, older.length]
+  )
 
 
   const value = useMemo(
   const value = useMemo(
     () => ({
     () => ({
@@ -171,7 +185,7 @@ export function WhatsNewProvider({
       {entries.length > 0 && !tabHidden && (
       {entries.length > 0 && !tabHidden && (
         <WhatsNewTab
         <WhatsNewTab
           hasUnread={pending.length > 0}
           hasUnread={pending.length > 0}
-          onOpen={open}
+          onOpen={() => open('tab')}
           onHide={() => {
           onHide={() => {
             setTabHidden(true)
             setTabHidden(true)
             // Said once, at the moment it goes, so the way back is known.
             // Said once, at the moment it goes, so the way back is known.

+ 13 - 0
src/features/whats-new/Lib/paging.ts

@@ -4,3 +4,16 @@
  * a long history costs nothing to the people who never open the panel.
  * a long history costs nothing to the people who never open the panel.
  */
  */
 export const WHATS_NEW_PAGE_SIZE = 10
 export const WHATS_NEW_PAGE_SIZE = 10
+
+/** How long an entry keeps its New or Improved label after it is published. */
+export const WHATS_NEW_FRESH_DAYS = 21
+
+/**
+ * Whether an entry is recent enough to be labelled. The panel keeps every
+ * entry, so without a limit a feature from last year would still say "New".
+ */
+export function isFreshEntry(publishedAt: string, now: number = Date.now()): boolean {
+  const published = Date.parse(publishedAt)
+  if (Number.isNaN(published)) return false
+  return now - published < WHATS_NEW_FRESH_DAYS * 24 * 60 * 60 * 1000
+}

+ 11 - 0
src/lib/analytics.ts

@@ -107,6 +107,17 @@ export type AnalyticsEvent =
   | 'fault_code:import_check'
   | 'fault_code:import_check'
   /** An import sent for review: its `make` and how many `codes`. */
   /** An import sent for review: its `make` and how many `codes`. */
   | 'fault_code:import_send'
   | 'fault_code:import_send'
+  /**
+   * The What's new panel opened: `source` tab (the edge of the screen), menu
+   * (the account menu), settings (Settings, About) or update_banner, with how
+   * many entries it held and how many were `unread`. Whether anybody reads
+   * the news at all, and from where.
+   */
+  | 'whats_new:panel_open'
+  /** A link on an entry followed: `link` button, guide or video, and the `entry_id`. */
+  | 'whats_new:entry_link_click'
+  /** The tab on the edge of the screen hidden, or brought back: `hidden` true or false. */
+  | 'whats_new:tab_toggle'
 
 
 type Properties = Record<string, string | number | boolean | null>
 type Properties = Record<string, string | number | boolean | null>