From be6b7e4655b177a60388b7fa64ecda4b4131969a Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 11 Jun 2026 15:33:33 -0400 Subject: [PATCH 1/8] BDMS-884: Add app refresh banner to notify users when a new version is deployed Adds a non-intrusive top banner that appears when a new build has been deployed while a user has the app open. Detects new versions by polling /version.json every 5 minutes and comparing against the build time captured at initial load. - vite.config.ts: new write-version-json plugin writes public/version.json with the current ISO build time at the start of every build - public/version.json: placeholder file for local dev (contains "dev") - src/hooks/useNewVersion.ts: polls /version.json, returns isNewVersionAvailable and dismiss(); stops detecting once the user dismisses the banner - src/components/NewVersionBanner.tsx: full-width indigo banner with "We just made Ocotillo 5% better. Refresh to get the good stuff!", a Refresh Now button that reloads the page, and an X to dismiss - src/components/AppShell.tsx: banner rendered above AppLayout in a flex-col wrapper so it takes only the space it needs and the shell fills the rest --- public/version.json | 1 + src/components/AppShell.tsx | 15 +++++--- src/components/NewVersionBanner.tsx | 31 ++++++++++++++++ src/hooks/useNewVersion.ts | 57 +++++++++++++++++++++++++++++ vite.config.ts | 13 +++++++ 5 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 public/version.json create mode 100644 src/components/NewVersionBanner.tsx create mode 100644 src/hooks/useNewVersion.ts diff --git a/public/version.json b/public/version.json new file mode 100644 index 00000000..1efcf1bb --- /dev/null +++ b/public/version.json @@ -0,0 +1 @@ +{"buildTime":"dev"} \ No newline at end of file diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index a0ede246..b8776356 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -63,6 +63,7 @@ import { ReportBugButton } from '@/components/Button' import { AmpRole, PRIMARY_NAV, RESOURCE_NAV, type NavItem } from '@/config/navigation' import { useAccessCapabilities } from '@/hooks' import { useSearch } from '@/providers/search-provider' +import { NewVersionBanner } from '@/components/NewVersionBanner' // Support panel state shared between the sidebar footer button and the panel itself export const SupportPanelContext = createContext<{ @@ -732,11 +733,13 @@ function AppShellInner({ children }: { children?: React.ReactNode }) { export const AppShell = ({ children }: { children?: React.ReactNode }) => { return ( - // h-svh + overflow-hidden pins the shell to exactly the viewport so no page - // can cause a body-level scroll. AppContent gets overflow-y-auto so regular - // pages still scroll within the frame. - - {children} - + // flex-col wrapper so NewVersionBanner can sit above the shell without + // breaking the h-svh constraint — AppLayout takes the remaining height. +
+ + + {children} + +
) } diff --git a/src/components/NewVersionBanner.tsx b/src/components/NewVersionBanner.tsx new file mode 100644 index 00000000..1388869b --- /dev/null +++ b/src/components/NewVersionBanner.tsx @@ -0,0 +1,31 @@ +import { X } from 'lucide-react' +import { useNewVersion } from '@/hooks/useNewVersion' + +export const NewVersionBanner = () => { + const { isNewVersionAvailable, dismiss } = useNewVersion() + + if (!isNewVersionAvailable) return null + + return ( +
+ + We just made Ocotillo 5% better. Refresh to get the good stuff! + +
+ + +
+
+ ) +} diff --git a/src/hooks/useNewVersion.ts b/src/hooks/useNewVersion.ts new file mode 100644 index 00000000..05367e9a --- /dev/null +++ b/src/hooks/useNewVersion.ts @@ -0,0 +1,57 @@ +import { useEffect, useRef, useState } from 'react' + +const POLL_INTERVAL_MS = 5 * 60 * 1000 // 5 minutes + +async function fetchBuildTime(): Promise { + try { + const res = await fetch('/version.json', { cache: 'no-store' }) + if (!res.ok) return null + const data = await res.json() + return data?.buildTime ?? null + } catch { + return null + } +} + +/** + * Polls /version.json every 5 minutes. Returns true once the server's + * buildTime differs from the value captured when the app first loaded, + * indicating that a new version has been deployed. + */ +export const useNewVersion = () => { + const [isNewVersionAvailable, setIsNewVersionAvailable] = useState(false) + const [dismissed, setDismissed] = useState(false) + const initialBuildTime = useRef(null) + + useEffect(() => { + let cancelled = false + + const check = async () => { + const buildTime = await fetchBuildTime() + if (cancelled) return + + if (initialBuildTime.current === null) { + // First fetch — record the version the user loaded with. + initialBuildTime.current = buildTime + return + } + + if (buildTime !== null && buildTime !== initialBuildTime.current) { + setIsNewVersionAvailable(true) + } + } + + check() + const timer = setInterval(check, POLL_INTERVAL_MS) + + return () => { + cancelled = true + clearInterval(timer) + } + }, []) + + return { + isNewVersionAvailable: isNewVersionAvailable && !dismissed, + dismiss: () => setDismissed(true), + } +} diff --git a/vite.config.ts b/vite.config.ts index 8d1844ae..f106c6f6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,8 @@ import react from '@vitejs/plugin-react' import tsconfigPaths from 'vite-tsconfig-paths' import { sentryVitePlugin } from '@sentry/vite-plugin' import tailwindcss from '@tailwindcss/vite' +import { writeFileSync } from 'fs' +import { resolve } from 'path' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd()) @@ -28,6 +30,17 @@ export default defineConfig(({ mode }) => { dedupe: ['react', 'react-dom', 'scheduler', 'use-sync-external-store'], }, plugins: [ + // Write public/version.json on every build so polling clients can detect + // that a new version has been deployed and prompt users to reload. + { + name: 'write-version-json', + buildStart() { + writeFileSync( + resolve(__dirname, 'public/version.json'), + JSON.stringify({ buildTime: new Date().toISOString() }) + ) + }, + }, tailwindcss(), react(), tsconfigPaths(), From cad9579b8b2e176a7662cfc440286aa355db678f Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 11 Jun 2026 15:36:56 -0400 Subject: [PATCH 2/8] Add ?preview-refresh-banner URL param for design review --- src/hooks/useNewVersion.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/hooks/useNewVersion.ts b/src/hooks/useNewVersion.ts index 05367e9a..e202f26a 100644 --- a/src/hooks/useNewVersion.ts +++ b/src/hooks/useNewVersion.ts @@ -17,9 +17,15 @@ async function fetchBuildTime(): Promise { * Polls /version.json every 5 minutes. Returns true once the server's * buildTime differs from the value captured when the app first loaded, * indicating that a new version has been deployed. + * + * Append ?preview-refresh-banner to any URL to force the banner visible for + * design review or screenshots. */ export const useNewVersion = () => { - const [isNewVersionAvailable, setIsNewVersionAvailable] = useState(false) + const forceShow = new URLSearchParams(window.location.search).has( + 'preview-refresh-banner' + ) + const [isNewVersionAvailable, setIsNewVersionAvailable] = useState(forceShow) const [dismissed, setDismissed] = useState(false) const initialBuildTime = useRef(null) From 843dbc713797b175153d995a7309e0cfa260f821 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 11 Jun 2026 15:39:44 -0400 Subject: [PATCH 3/8] Move banner inside content area, remove dismiss button --- src/components/AppShell.tsx | 12 ++++-------- src/components/NewVersionBanner.tsx | 24 +++++++----------------- src/hooks/useNewVersion.ts | 6 +----- 3 files changed, 12 insertions(+), 30 deletions(-) diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index b8776356..3ade48d0 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -721,6 +721,7 @@ function AppShellInner({ children }: { children?: React.ReactNode }) { +
{children ?? } @@ -733,13 +734,8 @@ function AppShellInner({ children }: { children?: React.ReactNode }) { export const AppShell = ({ children }: { children?: React.ReactNode }) => { return ( - // flex-col wrapper so NewVersionBanner can sit above the shell without - // breaking the h-svh constraint — AppLayout takes the remaining height. -
- - - {children} - -
+ + {children} + ) } diff --git a/src/components/NewVersionBanner.tsx b/src/components/NewVersionBanner.tsx index 1388869b..d772db89 100644 --- a/src/components/NewVersionBanner.tsx +++ b/src/components/NewVersionBanner.tsx @@ -1,8 +1,7 @@ -import { X } from 'lucide-react' import { useNewVersion } from '@/hooks/useNewVersion' export const NewVersionBanner = () => { - const { isNewVersionAvailable, dismiss } = useNewVersion() + const { isNewVersionAvailable } = useNewVersion() if (!isNewVersionAvailable) return null @@ -11,21 +10,12 @@ export const NewVersionBanner = () => { We just made Ocotillo 5% better. Refresh to get the good stuff! -
- - -
+
) } diff --git a/src/hooks/useNewVersion.ts b/src/hooks/useNewVersion.ts index e202f26a..f2b7bf00 100644 --- a/src/hooks/useNewVersion.ts +++ b/src/hooks/useNewVersion.ts @@ -26,7 +26,6 @@ export const useNewVersion = () => { 'preview-refresh-banner' ) const [isNewVersionAvailable, setIsNewVersionAvailable] = useState(forceShow) - const [dismissed, setDismissed] = useState(false) const initialBuildTime = useRef(null) useEffect(() => { @@ -56,8 +55,5 @@ export const useNewVersion = () => { } }, []) - return { - isNewVersionAvailable: isNewVersionAvailable && !dismissed, - dismiss: () => setDismissed(true), - } + return { isNewVersionAvailable } } From d1fb425ffa93df69bf47515dc150d680ebcc5bca Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 11 Jun 2026 15:41:52 -0400 Subject: [PATCH 4/8] Add pointer cursor to refresh button --- src/components/NewVersionBanner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/NewVersionBanner.tsx b/src/components/NewVersionBanner.tsx index d772db89..f0fb01f3 100644 --- a/src/components/NewVersionBanner.tsx +++ b/src/components/NewVersionBanner.tsx @@ -12,7 +12,7 @@ export const NewVersionBanner = () => { From 4ace48d8c8d59c937db73c078137ea1a6a3a5acd Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 11 Jun 2026 15:49:23 -0400 Subject: [PATCH 5/8] Use shadcn Button component in NewVersionBanner --- src/components/NewVersionBanner.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/NewVersionBanner.tsx b/src/components/NewVersionBanner.tsx index f0fb01f3..b09b51ee 100644 --- a/src/components/NewVersionBanner.tsx +++ b/src/components/NewVersionBanner.tsx @@ -1,3 +1,4 @@ +import { Button } from '@/components/ui/button' import { useNewVersion } from '@/hooks/useNewVersion' export const NewVersionBanner = () => { @@ -10,12 +11,14 @@ export const NewVersionBanner = () => { We just made Ocotillo 5% better. Refresh to get the good stuff! - + ) } From 5432d4ce47ca43ad1f9f6a285eeb612e1663dbd6 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 11 Jun 2026 15:56:26 -0400 Subject: [PATCH 6/8] update BG colors --- src/components/NewVersionBanner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/NewVersionBanner.tsx b/src/components/NewVersionBanner.tsx index b09b51ee..a23e154d 100644 --- a/src/components/NewVersionBanner.tsx +++ b/src/components/NewVersionBanner.tsx @@ -7,7 +7,7 @@ export const NewVersionBanner = () => { if (!isNewVersionAvailable) return null return ( -
+
We just made Ocotillo 5% better. Refresh to get the good stuff! From 6e01475df1200e03dc14fec5bfdf6fba19e9e5f3 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 11 Jun 2026 16:04:37 -0400 Subject: [PATCH 7/8] Show app version in sidebar footer --- src/components/AppShell.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 3ade48d0..b31e8e29 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -64,6 +64,7 @@ import { AmpRole, PRIMARY_NAV, RESOURCE_NAV, type NavItem } from '@/config/navig import { useAccessCapabilities } from '@/hooks' import { useSearch } from '@/providers/search-provider' import { NewVersionBanner } from '@/components/NewVersionBanner' +import pkg from '../../package.json' // Support panel state shared between the sidebar footer button and the panel itself export const SupportPanelContext = createContext<{ @@ -354,6 +355,14 @@ function AppSidebar() { {/* Help & Support button — hidden until panel content is defined */} {/* */} + + {!collapsed && ( +
+ + v{pkg.version} + +
+ )} ) From 2f62d4e7b4818b9cbbd23261fb53b51fbf44a9aa Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 11 Jun 2026 16:10:44 -0400 Subject: [PATCH 8/8] Set Cache-Control: no-store on version.json in GAE config --- app-staging.yaml | 7 +++++++ app.yaml | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/app-staging.yaml b/app-staging.yaml index 91789e20..966c5cb0 100644 --- a/app-staging.yaml +++ b/app-staging.yaml @@ -1,6 +1,13 @@ runtime: python313 service: ocotillo-staging handlers: + # version.json must never be cached so the new-version polling is reliable + - url: /version.json + static_files: dist/version.json + upload: dist/version.json + secure: always + http_headers: + Cache-Control: no-store # Serve all static files with url ending with a file extension - url: /(.*\..+)$ static_files: dist/\1 diff --git a/app.yaml b/app.yaml index b68d7d3f..b56b6b2c 100644 --- a/app.yaml +++ b/app.yaml @@ -1,6 +1,13 @@ runtime: python313 service: ocotillo handlers: + # version.json must never be cached so the new-version polling is reliable + - url: /version.json + static_files: dist/version.json + upload: dist/version.json + secure: always + http_headers: + Cache-Control: no-store # Serve all static files with url ending with a file extension - url: /(.*\..+)$ static_files: dist/\1