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 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..b31e8e29 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -63,6 +63,8 @@ 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' +import pkg from '../../package.json' // Support panel state shared between the sidebar footer button and the panel itself export const SupportPanelContext = createContext<{ @@ -353,6 +355,14 @@ function AppSidebar() { {/* Help & Support button — hidden until panel content is defined */} {/* */} + + {!collapsed && ( +
+ + v{pkg.version} + +
+ )} ) @@ -720,6 +730,7 @@ function AppShellInner({ children }: { children?: React.ReactNode }) { +
{children ?? } @@ -732,9 +743,6 @@ 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} diff --git a/src/components/NewVersionBanner.tsx b/src/components/NewVersionBanner.tsx new file mode 100644 index 00000000..a23e154d --- /dev/null +++ b/src/components/NewVersionBanner.tsx @@ -0,0 +1,24 @@ +import { Button } from '@/components/ui/button' +import { useNewVersion } from '@/hooks/useNewVersion' + +export const NewVersionBanner = () => { + const { isNewVersionAvailable } = 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..f2b7bf00 --- /dev/null +++ b/src/hooks/useNewVersion.ts @@ -0,0 +1,59 @@ +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. + * + * Append ?preview-refresh-banner to any URL to force the banner visible for + * design review or screenshots. + */ +export const useNewVersion = () => { + const forceShow = new URLSearchParams(window.location.search).has( + 'preview-refresh-banner' + ) + const [isNewVersionAvailable, setIsNewVersionAvailable] = useState(forceShow) + 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 } +} 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(),