nyk commited on
Commit
ce66f43
·
unverified ·
1 Parent(s): 932b27c

fix(ui): persist doctor banner dismiss with 24h expiry (#328)

Browse files

* fix(ui): persist doctor banner dismiss with 24h expiry (#320)

Replace ephemeral useState dismiss with Zustand store + localStorage.
Banner stays hidden for 24 hours after dismiss, then resurfaces so
users can re-check. Mirrors the existing UpdateBanner pattern.

Closes #320

* test: add unit tests for doctor banner dismiss persistence

Tests verify localStorage persistence, 24h expiry, page refresh
survival, and graceful handling of corrupted values.

src/components/layout/openclaw-doctor-banner.tsx CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import { useEffect, useState } from 'react'
4
  import { Button } from '@/components/ui/button'
 
5
 
6
  interface OpenClawDoctorStatus {
7
  level: 'healthy' | 'warning' | 'error'
@@ -23,7 +24,8 @@ type BannerState = 'idle' | 'fixing' | 'success' | 'error'
23
  export function OpenClawDoctorBanner() {
24
  const [doctor, setDoctor] = useState<OpenClawDoctorStatus | null>(null)
25
  const [loading, setLoading] = useState(true)
26
- const [dismissed, setDismissed] = useState(false)
 
27
  const [state, setState] = useState<BannerState>('idle')
28
  const [errorMsg, setErrorMsg] = useState<string | null>(null)
29
  const [showDetails, setShowDetails] = useState(false)
@@ -38,7 +40,6 @@ export function OpenClawDoctorBanner() {
38
  }
39
  const data = await res.json()
40
  setDoctor(data)
41
- setDismissed(false)
42
  } catch {
43
  setDoctor(null)
44
  } finally {
@@ -95,6 +96,9 @@ export function OpenClawDoctorBanner() {
95
  }
96
  }
97
 
 
 
 
98
  if (loading || dismissed || !doctor || doctor.healthy) return null
99
 
100
  const tone =
@@ -176,7 +180,7 @@ export function OpenClawDoctorBanner() {
176
  <Button
177
  variant="ghost"
178
  size="icon-xs"
179
- onClick={() => setDismissed(true)}
180
  className="shrink-0 hover:bg-transparent"
181
  title="Dismiss"
182
  >
 
2
 
3
  import { useEffect, useState } from 'react'
4
  import { Button } from '@/components/ui/button'
5
+ import { useMissionControl } from '@/store'
6
 
7
  interface OpenClawDoctorStatus {
8
  level: 'healthy' | 'warning' | 'error'
 
24
  export function OpenClawDoctorBanner() {
25
  const [doctor, setDoctor] = useState<OpenClawDoctorStatus | null>(null)
26
  const [loading, setLoading] = useState(true)
27
+ const doctorDismissedAt = useMissionControl(s => s.doctorDismissedAt)
28
+ const dismissDoctor = useMissionControl(s => s.dismissDoctor)
29
  const [state, setState] = useState<BannerState>('idle')
30
  const [errorMsg, setErrorMsg] = useState<string | null>(null)
31
  const [showDetails, setShowDetails] = useState(false)
 
40
  }
41
  const data = await res.json()
42
  setDoctor(data)
 
43
  } catch {
44
  setDoctor(null)
45
  } finally {
 
96
  }
97
  }
98
 
99
+ const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000
100
+ const dismissed = doctorDismissedAt != null && (Date.now() - doctorDismissedAt) < TWENTY_FOUR_HOURS
101
+
102
  if (loading || dismissed || !doctor || doctor.healthy) return null
103
 
104
  const tone =
 
180
  <Button
181
  variant="ghost"
182
  size="icon-xs"
183
+ onClick={dismissDoctor}
184
  className="shrink-0 hover:bg-transparent"
185
  title="Dismiss"
186
  >
src/lib/__tests__/doctor-banner-dismiss.test.ts ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
2
+
3
+ /**
4
+ * Tests for the doctor banner dismiss persistence logic.
5
+ * Mirrors the store + banner integration for doctorDismissedAt.
6
+ */
7
+
8
+ const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000
9
+ const LS_KEY = 'mc-doctor-dismissed-at'
10
+
11
+ // Simulate the store logic
12
+ function createDoctorDismissStore() {
13
+ let storage: Record<string, string> = {}
14
+
15
+ return {
16
+ getItem: (key: string) => storage[key] ?? null,
17
+ setItem: (key: string, val: string) => { storage[key] = val },
18
+ removeItem: (key: string) => { delete storage[key] },
19
+ clear: () => { storage = {} },
20
+
21
+ // Store state
22
+ doctorDismissedAt: null as number | null,
23
+
24
+ // Initialize from localStorage (mirrors store IIFE)
25
+ init() {
26
+ const raw = this.getItem(LS_KEY)
27
+ this.doctorDismissedAt = raw ? Number(raw) : null
28
+ },
29
+
30
+ // Action (mirrors store.dismissDoctor)
31
+ dismissDoctor() {
32
+ const now = Date.now()
33
+ this.setItem(LS_KEY, String(now))
34
+ this.doctorDismissedAt = now
35
+ },
36
+
37
+ // Check if dismissed (mirrors banner logic)
38
+ isDismissed() {
39
+ return this.doctorDismissedAt != null && (Date.now() - this.doctorDismissedAt) < TWENTY_FOUR_HOURS
40
+ },
41
+ }
42
+ }
43
+
44
+ describe('doctor banner dismiss persistence', () => {
45
+ let store: ReturnType<typeof createDoctorDismissStore>
46
+
47
+ beforeEach(() => {
48
+ store = createDoctorDismissStore()
49
+ })
50
+
51
+ it('is not dismissed by default', () => {
52
+ store.init()
53
+ expect(store.isDismissed()).toBe(false)
54
+ expect(store.doctorDismissedAt).toBeNull()
55
+ })
56
+
57
+ it('persists dismiss to localStorage', () => {
58
+ store.dismissDoctor()
59
+ expect(store.getItem(LS_KEY)).toBeDefined()
60
+ expect(Number(store.getItem(LS_KEY))).toBeGreaterThan(0)
61
+ })
62
+
63
+ it('is dismissed immediately after calling dismissDoctor', () => {
64
+ store.dismissDoctor()
65
+ expect(store.isDismissed()).toBe(true)
66
+ })
67
+
68
+ it('survives re-initialization (simulates page refresh)', () => {
69
+ store.dismissDoctor()
70
+
71
+ // Simulate fresh store init (page refresh)
72
+ const store2 = createDoctorDismissStore()
73
+ // Copy storage state
74
+ const raw = store.getItem(LS_KEY)
75
+ if (raw) store2.setItem(LS_KEY, raw)
76
+ store2.init()
77
+
78
+ expect(store2.isDismissed()).toBe(true)
79
+ expect(store2.doctorDismissedAt).toEqual(store.doctorDismissedAt)
80
+ })
81
+
82
+ it('expires after 24 hours', () => {
83
+ const realNow = Date.now
84
+ try {
85
+ const baseTime = 1700000000000
86
+ Date.now = vi.fn(() => baseTime)
87
+
88
+ store.dismissDoctor()
89
+ expect(store.isDismissed()).toBe(true)
90
+
91
+ // Jump forward 23 hours — still dismissed
92
+ Date.now = vi.fn(() => baseTime + 23 * 60 * 60 * 1000)
93
+ expect(store.isDismissed()).toBe(true)
94
+
95
+ // Jump forward 25 hours — no longer dismissed
96
+ Date.now = vi.fn(() => baseTime + 25 * 60 * 60 * 1000)
97
+ expect(store.isDismissed()).toBe(false)
98
+ } finally {
99
+ Date.now = realNow
100
+ }
101
+ })
102
+
103
+ it('handles corrupted localStorage value gracefully', () => {
104
+ store.setItem(LS_KEY, 'not-a-number')
105
+ store.init()
106
+ // NaN from Number("not-a-number") — Date.now() - NaN is NaN, < 24h is false
107
+ expect(store.isDismissed()).toBe(false)
108
+ })
109
+ })
src/store/index.ts CHANGED
@@ -394,6 +394,10 @@ interface MissionControlStore {
394
  setOpenclawUpdate: (info: { installed: string; latest: string; releaseUrl: string; releaseNotes: string; updateCommand: string } | null) => void
395
  dismissOpenclawUpdate: (version: string) => void
396
 
 
 
 
 
397
  // WebSocket & Connection
398
  connection: ConnectionStatus
399
  lastMessage: unknown
@@ -633,6 +637,20 @@ export const useMissionControl = create<MissionControlStore>()(
633
  set({ openclawUpdateDismissedVersion: version })
634
  },
635
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
636
  // Connection state
637
  connection: {
638
  isConnected: false,
 
394
  setOpenclawUpdate: (info: { installed: string; latest: string; releaseUrl: string; releaseNotes: string; updateCommand: string } | null) => void
395
  dismissOpenclawUpdate: (version: string) => void
396
 
397
+ // OpenClaw Doctor banner dismiss (persisted with 24h expiry)
398
+ doctorDismissedAt: number | null
399
+ dismissDoctor: () => void
400
+
401
  // WebSocket & Connection
402
  connection: ConnectionStatus
403
  lastMessage: unknown
 
637
  set({ openclawUpdateDismissedVersion: version })
638
  },
639
 
640
+ // OpenClaw Doctor banner dismiss
641
+ doctorDismissedAt: (() => {
642
+ if (typeof window === 'undefined') return null
643
+ try {
644
+ const raw = localStorage.getItem('mc-doctor-dismissed-at')
645
+ return raw ? Number(raw) : null
646
+ } catch { return null }
647
+ })(),
648
+ dismissDoctor: () => {
649
+ const now = Date.now()
650
+ try { localStorage.setItem('mc-doctor-dismissed-at', String(now)) } catch {}
651
+ set({ doctorDismissedAt: now })
652
+ },
653
+
654
  // Connection state
655
  connection: {
656
  isConnected: false,