mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
fix(frontend): calm update and star prompts
This commit is contained in:
@@ -954,13 +954,22 @@ upgrade, update, release, or artifact-selection behavior.
|
||||
unavailable for source/development builds, and return the canonical release
|
||||
body without inventing a second changelog source. The update banner may
|
||||
preview only the curated `Highlights` section from update-check metadata,
|
||||
while the post-update card must render the release's categorized user-facing
|
||||
while the post-update card must offer the release's categorized user-facing
|
||||
change sections (`Added`, `Improved`/`Changed`, `Fixed`, `Security`,
|
||||
`Breaking changes`, `Deprecated`, or `Removed`) as a changelog once per
|
||||
later installed release. It must not reuse the Highlights summary as the
|
||||
post-update content, and must stay silent for a first baseline, malformed or
|
||||
development versions, missing releases, and releases without categorized
|
||||
changes. `Highlights` remains a pre-update overview only: release rendering
|
||||
later installed release. Automatic release communication is limited to a
|
||||
compact non-blocking update notice; the detailed changelog may open only
|
||||
after explicit operator action. Preparing that notice records the version
|
||||
immediately so a reload cannot turn it into a recurring prompt. When the
|
||||
one-time telemetry disclosure owns the same session, it suppresses the
|
||||
lower-priority release notice instead of creating consecutive notices.
|
||||
`frontend-modern/src/utils/localStorage.ts` owns that browser-session notice
|
||||
reservation boundary so the release notice, telemetry disclosure, and
|
||||
GitHub gratitude prompt cannot create a one-two sequence. The post-update
|
||||
surface must not reuse the Highlights summary as its content,
|
||||
and must stay silent for a first baseline, malformed or development
|
||||
versions, missing releases, and releases without categorized changes.
|
||||
`Highlights` remains a pre-update overview only: release rendering
|
||||
keeps it to at most three short plain-text bullets of no more than 140
|
||||
characters each, with links, code, issue references, and nested structure
|
||||
reserved for the categorized or full release notes.
|
||||
|
||||
@@ -1080,6 +1080,9 @@ not a replacement status card, CTA band, or page-local nested card.
|
||||
recurring snooze behavior. It must yield the browser session to an existing
|
||||
release, disclosure, or other low-priority app-shell notice and suppress
|
||||
itself for the remainder of a session that presents a blocking dialog.
|
||||
Low-priority app-shell consumers must coordinate through
|
||||
`reserveLowPriorityNoticeSession` rather than restoring component-local
|
||||
cooldowns that can produce consecutive prompts.
|
||||
Settings selection helpers such as `ResourcePicker` must use the same
|
||||
`Button` primitive for select-all, clear, and chip remove actions instead of
|
||||
restoring footer-local action shells.
|
||||
@@ -4994,7 +4997,8 @@ Clipboard API fallback path and only report copied state after the shared copy
|
||||
path succeeds.
|
||||
The running published version must keep a direct `Current release notes` link
|
||||
in this settings shell so suppressing or dismissing the one-time post-update
|
||||
notice never makes the changelog undiscoverable.
|
||||
notice never makes the changelog undiscoverable. Development and source-build
|
||||
identities must not render that link as if they named a published release.
|
||||
|
||||
The update verdict is honest about its age. The frontend serves the update
|
||||
verdict from a 24-hour localStorage cache, so the panel's "Up to date" state
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "af03903a734f6741360748fbe661cccb45c880ca",
|
||||
"verified_at": "2026-08-25T19:48:51Z",
|
||||
"base_sha": "51d49474e461eaca83a75065ba370f3092d6ba34",
|
||||
"verified_at": "2026-08-25T20:20:32Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Storage/DiskDetail.tsx",
|
||||
"frontend-modern/src/components/Storage/StorageDetailKeyValueRow.tsx"
|
||||
"frontend-modern/src/components/GitHubStarBanner.tsx",
|
||||
"frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx",
|
||||
"frontend-modern/src/components/WhatsNewCard.tsx",
|
||||
"frontend-modern/src/utils/localStorage.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Storage/DiskDetail.tsx": "19487ce71ef93ed4db8c8a36bb76790298408a449313bcb19e7266b1b41e0afc",
|
||||
"frontend-modern/src/components/Storage/StorageDetailKeyValueRow.tsx": "bc045c2a053c395154c9a4d0ff5bac32bd1c129ab579032ce282e81acd623426"
|
||||
"frontend-modern/src/components/GitHubStarBanner.tsx": "7c5b51cb927fa778bd6bb201793a20c084d4e3f6bfab39feb4573fe2759f8c85",
|
||||
"frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx": "b0cb063a25f0c9a966522b740afa962163d87a50e241030ac289f1a5679063ad",
|
||||
"frontend-modern/src/components/WhatsNewCard.tsx": "5e5d7314e46b8aa496f73a42e1abc37a294a3f3ebff3c3c424c33f8d615961d0",
|
||||
"frontend-modern/src/utils/localStorage.ts": "452dbaa0e4014e0af635b24502ffbe53c738a449670ac6b4c381b3033c53ee0a"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/storage"
|
||||
"/prompt-verification.html",
|
||||
"/proxmox/overview",
|
||||
"/settings/system-updates"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
@@ -25,17 +31,17 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"degraded ZFS pool Overview with long node and content values",
|
||||
"degraded ZFS pool History with collection availability messaging",
|
||||
"critical NVMe disk Overview without detailed SMART attributes",
|
||||
"physical disk History with live I/O and SMART history"
|
||||
"compact post-update notice over an active infrastructure page without a blocking backdrop",
|
||||
"opt-in categorized release changelog dialog at desktop and phone widths",
|
||||
"one-time GitHub star prompt at desktop and phone widths",
|
||||
"dismissed release notice, changelog dialog, and star prompt states",
|
||||
"System Updates current-version panel in the signed-in development runtime"
|
||||
],
|
||||
"interactions": [
|
||||
"opened the Storage and Physical Disks tabs",
|
||||
"expanded standalone1-service-pool and switched between Overview and History",
|
||||
"expanded the WD Blue disk and switched between Overview and History",
|
||||
"confirmed mobile key-value stacking and wrapping without clipping",
|
||||
"confirmed the two-column desktop configuration grid remains intact",
|
||||
"restored the browser viewport after responsive verification"
|
||||
"opened the changelog from See what's new and closed it with both Escape and Got it",
|
||||
"dismissed the compact update notice without opening the changelog",
|
||||
"closed the GitHub star prompt and confirmed it left the page cleanly",
|
||||
"navigated the signed-in System Updates page and confirmed development builds do not claim published release notes",
|
||||
"restored the browser viewport and closed all verification tabs"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -720,7 +720,7 @@
|
||||
{
|
||||
"id": "github-star-banner-action-shell",
|
||||
"category": "action-button",
|
||||
"summary": "The GitHub star prompt must compose Button and ActionIconButton for its primary, defer, and dismiss actions instead of carrying local floating-prompt button shells.",
|
||||
"summary": "The one-time GitHub star prompt must compose Button and ActionIconButton for its primary and permanent-dismiss actions instead of carrying local floating-prompt button shells.",
|
||||
"canonical": {
|
||||
"path": "src/components/shared/Button.tsx",
|
||||
"export": "Button"
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { Show, createSignal, createEffect, onCleanup } from 'solid-js';
|
||||
import { Show, createSignal, createEffect } from 'solid-js';
|
||||
import {
|
||||
createLocalStorageBooleanSignal,
|
||||
createLocalStorageNumberSignal,
|
||||
createLocalStorageStringSignal,
|
||||
reserveLowPriorityNoticeSession,
|
||||
STORAGE_KEYS,
|
||||
} from '@/utils/localStorage';
|
||||
import { useWebSocket } from '@/contexts/appRuntime';
|
||||
import { ActionIconButton, Button } from '@/components/shared/Button';
|
||||
import { dialogStackHasBlockingDialog } from '@/components/shared/useDialogState';
|
||||
import GithubIcon from 'lucide-solid/icons/github';
|
||||
import StarIcon from 'lucide-solid/icons/star';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
|
||||
const GITHUB_REPO_URL = 'https://github.com/rcourtman/Pulse';
|
||||
const ACTIVE_DAYS_BEFORE_PROMPT = 14;
|
||||
|
||||
function getTodayDateString(): string {
|
||||
return new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
@@ -19,127 +23,111 @@ function getTodayDateString(): string {
|
||||
export function GitHubStarBanner() {
|
||||
const { initialDataReceived, state } = useWebSocket();
|
||||
|
||||
// Track if user has dismissed the modal (permanent)
|
||||
// Closing the prompt is permanent; this is gratitude, not a workflow.
|
||||
const [dismissed, setDismissed] = createLocalStorageBooleanSignal(
|
||||
STORAGE_KEYS.GITHUB_STAR_DISMISSED,
|
||||
false,
|
||||
);
|
||||
|
||||
// Track the first date user had infrastructure connected
|
||||
const [firstSeenDate, setFirstSeenDate] = createLocalStorageStringSignal(
|
||||
STORAGE_KEYS.GITHUB_STAR_FIRST_SEEN,
|
||||
// A rendered prompt counts as its single lifetime appearance even if the
|
||||
// browser closes before the user chooses an action.
|
||||
const [promptShown, setPromptShown] = createLocalStorageBooleanSignal(
|
||||
STORAGE_KEYS.GITHUB_STAR_PROMPT_SHOWN,
|
||||
false,
|
||||
);
|
||||
|
||||
const [activeDays, setActiveDays] = createLocalStorageNumberSignal(
|
||||
STORAGE_KEYS.GITHUB_STAR_ACTIVE_DAYS,
|
||||
0,
|
||||
);
|
||||
|
||||
const [lastActiveDate, setLastActiveDate] = createLocalStorageStringSignal(
|
||||
STORAGE_KEYS.GITHUB_STAR_LAST_ACTIVE_DATE,
|
||||
'',
|
||||
);
|
||||
|
||||
// Track snooze date (when "Maybe later" was clicked, don't show again until this date)
|
||||
const [snoozedUntil, setSnoozedUntil] = createLocalStorageStringSignal(
|
||||
STORAGE_KEYS.GITHUB_STAR_SNOOZED_UNTIL,
|
||||
'',
|
||||
);
|
||||
const [showPrompt, setShowPrompt] = createSignal(false);
|
||||
let blockedByDialogThisSession = false;
|
||||
|
||||
const [showModal, setShowModal] = createSignal(false);
|
||||
|
||||
// Check if user qualifies to see the modal
|
||||
// Count distinct days with connected infrastructure, then offer one quiet
|
||||
// prompt only when the rest of the app shell has yielded the session.
|
||||
createEffect(() => {
|
||||
// Already dismissed? Don't show
|
||||
if (dismissed()) {
|
||||
setShowModal(false);
|
||||
setShowPrompt(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (promptShown()) return;
|
||||
|
||||
if (dialogStackHasBlockingDialog()) {
|
||||
blockedByDialogThisSession = true;
|
||||
setShowPrompt(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (blockedByDialogThisSession) return;
|
||||
|
||||
if (!initialDataReceived()) {
|
||||
setShowModal(false);
|
||||
setShowPrompt(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has connected infrastructure
|
||||
const hasInfrastructure = (state.resources || []).length > 0;
|
||||
|
||||
if (!hasInfrastructure) {
|
||||
setShowModal(false);
|
||||
setShowPrompt(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const today = getTodayDateString();
|
||||
const firstSeen = firstSeenDate();
|
||||
|
||||
// First time seeing infrastructure? Record the date, don't show yet
|
||||
if (!firstSeen) {
|
||||
setFirstSeenDate(today);
|
||||
setShowModal(false);
|
||||
if (lastActiveDate() !== today) {
|
||||
setLastActiveDate(today);
|
||||
setActiveDays(Math.min(activeDays() + 1, ACTIVE_DAYS_BEFORE_PROMPT));
|
||||
return;
|
||||
}
|
||||
|
||||
// Still within snooze period? Don't show
|
||||
const snoozeDate = snoozedUntil();
|
||||
if (snoozeDate && today < snoozeDate) {
|
||||
setShowModal(false);
|
||||
return;
|
||||
}
|
||||
if (activeDays() < ACTIVE_DAYS_BEFORE_PROMPT) return;
|
||||
if (!reserveLowPriorityNoticeSession('github-star')) return;
|
||||
|
||||
// Returning user (different day than first seen)? Show the modal
|
||||
if (firstSeen !== today) {
|
||||
setShowModal(true);
|
||||
}
|
||||
setShowPrompt(true);
|
||||
setPromptShown(true);
|
||||
});
|
||||
|
||||
const handleDismiss = () => {
|
||||
setDismissed(true);
|
||||
setShowModal(false);
|
||||
setShowPrompt(false);
|
||||
};
|
||||
|
||||
const handleStarClick = () => {
|
||||
window.open(GITHUB_REPO_URL, '_blank', 'noopener,noreferrer');
|
||||
// Auto-dismiss - trust that they starred
|
||||
setDismissed(true);
|
||||
setShowModal(false);
|
||||
setShowPrompt(false);
|
||||
};
|
||||
|
||||
const handleMaybeLater = () => {
|
||||
// Snooze for 7 days before showing again
|
||||
const snoozeDate = new Date();
|
||||
snoozeDate.setDate(snoozeDate.getDate() + 7);
|
||||
setSnoozedUntil(snoozeDate.toISOString().split('T')[0]);
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (!showModal()) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
handleMaybeLater();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
onCleanup(() => document.removeEventListener('keydown', handleKeyDown));
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={showModal()}>
|
||||
<Show when={showPrompt()}>
|
||||
<section
|
||||
class="fixed left-4 right-20 bottom-[var(--pulse-mobile-nav-height)] z-30 max-w-md overflow-hidden rounded-lg border border-border bg-surface text-base-content shadow-xl md:right-auto md:bottom-4"
|
||||
class="fixed left-4 right-20 bottom-[var(--pulse-mobile-nav-height)] z-30 max-w-sm overflow-hidden rounded-lg border border-border bg-surface text-base-content shadow-lg md:right-auto md:bottom-4"
|
||||
aria-labelledby="github-star-title"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="flex items-start gap-3 p-4">
|
||||
<div class="flex items-start gap-3 p-3">
|
||||
<div
|
||||
class="relative mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-hover"
|
||||
class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-hover"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<GithubIcon class="h-5 w-5 text-base-content" />
|
||||
<div class="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-yellow-400 shadow-sm">
|
||||
<StarIcon class="h-3 w-3 text-yellow-800" />
|
||||
</div>
|
||||
<GithubIcon class="h-4 w-4 text-base-content" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 id="github-star-title" class="text-sm font-semibold text-base-content">
|
||||
Enjoying Pulse?
|
||||
Finding Pulse useful?
|
||||
</h2>
|
||||
<p class="mt-1 text-xs leading-5 text-muted">
|
||||
Pulse is built and maintained by an independent developer. If it's been useful for
|
||||
monitoring your infrastructure, a GitHub star helps more than you'd think.
|
||||
Starring the project on GitHub helps others discover it.
|
||||
</p>
|
||||
</div>
|
||||
<ActionIconButton
|
||||
@@ -153,7 +141,7 @@ export function GitHubStarBanner() {
|
||||
<XIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</ActionIconButton>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={handleStarClick}
|
||||
variant="primary"
|
||||
@@ -164,9 +152,6 @@ export function GitHubStarBanner() {
|
||||
<StarIcon class="h-4 w-4" aria-hidden="true" />
|
||||
Star on GitHub
|
||||
</Button>
|
||||
<Button onClick={handleMaybeLater} variant="ghost" size="mdCompact" type="button">
|
||||
Maybe later
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,9 +12,15 @@ import ArrowRight from 'lucide-solid/icons/arrow-right';
|
||||
import Package from 'lucide-solid/icons/package';
|
||||
import type { UpdateInfo, VersionInfo, UpdatePlan } from '@/api/updates';
|
||||
import { runtimeCapabilities } from '@/stores/license';
|
||||
import { buildDockerImageTag, buildLinuxAmd64DownloadCommand } from '@/components/updateVersion';
|
||||
import {
|
||||
buildDockerImageTag,
|
||||
buildLinuxAmd64DownloadCommand,
|
||||
buildReleaseNotesUrl,
|
||||
} from '@/components/updateVersion';
|
||||
import { isReleaseVersion } from '@/components/whatsNewModel';
|
||||
import { UpdateInstallGuide } from '@/components/Settings/UpdateInstallGuide';
|
||||
import { UpdateHistorySection } from '@/components/Settings/UpdateHistorySection';
|
||||
import { ExternalTextLink } from '@/components/shared/ExternalTextLink';
|
||||
import {
|
||||
getUpdateChannelCardOptions,
|
||||
type UpdateChannelOptionValue,
|
||||
@@ -122,6 +128,21 @@ export const UpdatesSettingsPanel: Component<UpdatesSettingsPanelProps> = (props
|
||||
{(badge) => <span class={badge.className}>{badge.label}</span>}
|
||||
</For>
|
||||
</div>
|
||||
<Show
|
||||
when={
|
||||
!props.versionInfo()?.isDevelopment &&
|
||||
!props.versionInfo()?.isSourceBuild &&
|
||||
isReleaseVersion(props.versionInfo()?.version ?? '')
|
||||
}
|
||||
>
|
||||
<ExternalTextLink
|
||||
href={buildReleaseNotesUrl(props.versionInfo()?.version)}
|
||||
variant="compactAction"
|
||||
class="mt-1 -ml-1"
|
||||
>
|
||||
Current release notes
|
||||
</ExternalTextLink>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,40 @@ describe('settings switch labels', () => {
|
||||
expect(screen.getByRole('checkbox', { name: 'Automatic Stable Updates' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the running release notes available from update settings', () => {
|
||||
const [updateChannel, setUpdateChannel] = createSignal<'stable' | 'rc'>('stable');
|
||||
const [autoUpdateEnabled, setAutoUpdateEnabled] = createSignal(false);
|
||||
|
||||
render(() => (
|
||||
<UpdatesSettingsPanel
|
||||
versionInfo={() => ({
|
||||
version: '6.4.0',
|
||||
build: 'community',
|
||||
runtime: 'server',
|
||||
isDocker: false,
|
||||
isSourceBuild: false,
|
||||
isDevelopment: false,
|
||||
})}
|
||||
updateInfo={() => null}
|
||||
checkingForUpdates={() => false}
|
||||
updateChannel={updateChannel}
|
||||
setUpdateChannel={setUpdateChannel}
|
||||
autoUpdateEnabled={autoUpdateEnabled}
|
||||
setAutoUpdateEnabled={setAutoUpdateEnabled}
|
||||
checkForUpdates={vi.fn().mockResolvedValue(undefined)}
|
||||
setHasUnsavedChanges={vi.fn()}
|
||||
updatePlan={() => null}
|
||||
onInstallUpdate={vi.fn()}
|
||||
isInstalling={() => false}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Current release notes' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/rcourtman/Pulse/releases/tag/v6.4.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('associates the backup polling switch with its visible label', () => {
|
||||
const [backupPollingEnabled, setBackupPollingEnabled] = createSignal(true);
|
||||
const [backupPollingInterval, setBackupPollingInterval] = createSignal(3600);
|
||||
|
||||
@@ -1435,6 +1435,15 @@ describe('settings architecture guardrails', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps published release notes discoverable from the current-version card', () => {
|
||||
expect(updatesSettingsPanelSource).toContain('Current release notes');
|
||||
expect(updatesSettingsPanelSource).toContain('buildReleaseNotesUrl');
|
||||
expect(updatesSettingsPanelSource).toContain('isReleaseVersion');
|
||||
expect(updatesSettingsPanelSource).toContain('!props.versionInfo()?.isDevelopment');
|
||||
expect(updatesSettingsPanelSource).toContain('!props.versionInfo()?.isSourceBuild');
|
||||
expect(updatesSettingsPanelSource).toContain('variant="compactAction"');
|
||||
});
|
||||
|
||||
it('keeps the update verdict honest about the age of the check behind it', () => {
|
||||
// The displayed verdict can come from the 24h localStorage cache, so the
|
||||
// Up to date state must carry the age of the check it came from through
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Show, createEffect, createSignal } from 'solid-js';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import CheckCircleIcon from 'lucide-solid/icons/check-circle';
|
||||
import InfoIcon from 'lucide-solid/icons/info';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import { updateStore } from '@/stores/updates';
|
||||
import { UpdatesAPI } from '@/api/updates';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import { reserveLowPriorityNoticeSession, STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import { ActionIconButton, Button } from '@/components/shared/Button';
|
||||
import { Dialog } from '@/components/shared/Dialog';
|
||||
import { ExternalTextLink } from '@/components/shared/ExternalTextLink';
|
||||
@@ -55,10 +56,11 @@ const markTelemetryPayloadNoticeSeen = () => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Post-update "What's New" dialog. Shows once after the running version
|
||||
* changes, and only when that release has categorized user-facing changelog
|
||||
* entries in its GitHub release notes. Dismissing (or a release without those
|
||||
* entries) records the version so the dialog stays quiet until the next update.
|
||||
* Post-update "What's New" notice. A compact non-blocking notice appears once
|
||||
* after the running version changes and only when that release has categorized
|
||||
* user-facing changelog entries. The detailed dialog opens only after an
|
||||
* explicit action. Preparing the notice (or finding no categorized entries)
|
||||
* records the version so reloads stay quiet until the next update.
|
||||
*
|
||||
* This release communication boundary also owns the one-time, non-blocking
|
||||
* telemetry schema v2 notice. Existing installations see it once; fresh
|
||||
@@ -66,15 +68,22 @@ const markTelemetryPayloadNoticeSeen = () => {
|
||||
*/
|
||||
export function WhatsNewCard() {
|
||||
const navigate = useNavigate();
|
||||
const [visible, setVisible] = createSignal(false);
|
||||
const [noticeVisible, setNoticeVisible] = createSignal(false);
|
||||
const [dialogVisible, setDialogVisible] = createSignal(false);
|
||||
const [telemetryNoticeVisible, setTelemetryNoticeVisible] = createSignal(false);
|
||||
const [version, setVersion] = createSignal('');
|
||||
const [changelogHtml, setChangelogHtml] = createSignal('');
|
||||
const hadPriorReleaseBaseline = readLastSeenVersion() !== null;
|
||||
const telemetryNoticeAlreadySeen =
|
||||
readTelemetryPayloadNoticeVersion() === TELEMETRY_PAYLOAD_NOTICE_VERSION;
|
||||
const telemetryNoticeNeedsSession = hadPriorReleaseBaseline && !telemetryNoticeAlreadySeen;
|
||||
if (telemetryNoticeNeedsSession) {
|
||||
reserveLowPriorityNoticeSession('telemetry-update');
|
||||
}
|
||||
let checked = false;
|
||||
let telemetryNoticeChecked = false;
|
||||
|
||||
const loadNotes = async (currentVersion: string) => {
|
||||
const loadNotes = async (currentVersion: string, noticeSlotReserved: boolean) => {
|
||||
try {
|
||||
const notes = await UpdatesAPI.getReleaseNotes();
|
||||
// Mid-update the backend can briefly disagree with the UI about the
|
||||
@@ -89,7 +98,12 @@ export function WhatsNewCard() {
|
||||
}
|
||||
setChangelogHtml(renderMarkdown(changelog));
|
||||
setVersion(currentVersion);
|
||||
setVisible(true);
|
||||
// The compact notice is the only automatic release UI. Record the
|
||||
// version immediately so reloads cannot turn it into a recurring prompt.
|
||||
markVersionSeen(currentVersion);
|
||||
if (noticeSlotReserved) {
|
||||
setNoticeVisible(true);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as { status?: number }).status === 404) {
|
||||
// No published release for this build — stop asking.
|
||||
@@ -124,7 +138,8 @@ export function WhatsNewCard() {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadNotes(currentVersion);
|
||||
const noticeSlotReserved = reserveLowPriorityNoticeSession('release-update');
|
||||
void loadNotes(currentVersion, noticeSlotReserved);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
@@ -149,10 +164,17 @@ export function WhatsNewCard() {
|
||||
setTelemetryNoticeVisible(true);
|
||||
});
|
||||
|
||||
// Any close path (button, backdrop, Escape) counts as seen.
|
||||
const dismiss = () => {
|
||||
markVersionSeen(version());
|
||||
setVisible(false);
|
||||
const dismissNotice = () => {
|
||||
setNoticeVisible(false);
|
||||
};
|
||||
|
||||
const openChangelog = () => {
|
||||
setNoticeVisible(false);
|
||||
setDialogVisible(true);
|
||||
};
|
||||
|
||||
const dismissChangelog = () => {
|
||||
setDialogVisible(false);
|
||||
};
|
||||
|
||||
const dismissTelemetryNotice = () => {
|
||||
@@ -212,10 +234,32 @@ export function WhatsNewCard() {
|
||||
</InlineNotice>
|
||||
</Show>
|
||||
|
||||
<Show when={visible()}>
|
||||
<Show when={noticeVisible()}>
|
||||
<aside
|
||||
class="fixed bottom-[var(--pulse-mobile-nav-height)] left-4 right-4 z-30 max-w-sm md:right-auto md:bottom-4"
|
||||
aria-live="polite"
|
||||
data-testid="whats-new-notice"
|
||||
>
|
||||
<InlineNotice
|
||||
tone="success"
|
||||
icon={<CheckCircleIcon class="h-4 w-4" aria-hidden="true" />}
|
||||
actionLabel="See what's new"
|
||||
actionOnClick={openChangelog}
|
||||
actionAriaLabel={`See what's new in Pulse ${version()}`}
|
||||
onDismiss={dismissNotice}
|
||||
dismissLabel="Dismiss update notice"
|
||||
dismissTitle="Dismiss"
|
||||
class="shadow-lg"
|
||||
>
|
||||
<span class="font-semibold">Pulse updated to v{version()}</span>
|
||||
</InlineNotice>
|
||||
</aside>
|
||||
</Show>
|
||||
|
||||
<Show when={dialogVisible()}>
|
||||
<Dialog
|
||||
isOpen={visible()}
|
||||
onClose={dismiss}
|
||||
isOpen={dialogVisible()}
|
||||
onClose={dismissChangelog}
|
||||
panelClass="max-w-xl"
|
||||
ariaLabelledBy="whats-new-title"
|
||||
>
|
||||
@@ -250,7 +294,7 @@ export function WhatsNewCard() {
|
||||
</div>
|
||||
</div>
|
||||
<ActionIconButton
|
||||
onClick={dismiss}
|
||||
onClick={dismissChangelog}
|
||||
label="Dismiss what's new"
|
||||
title="Close"
|
||||
tone="muted"
|
||||
@@ -276,7 +320,7 @@ export function WhatsNewCard() {
|
||||
>
|
||||
Full release notes →
|
||||
</ExternalTextLink>
|
||||
<Button onClick={dismiss} variant="primary" size="md" type="button">
|
||||
<Button onClick={dismissChangelog} variant="primary" size="md" type="button">
|
||||
Got it
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { dialogStackHasBlockingDialog } from '@/components/shared/useDialogState';
|
||||
import gitHubStarBannerSource from '../GitHubStarBanner.tsx?raw';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mocks */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type BannerResource = { id: string; name: string };
|
||||
|
||||
const wsState = vi.hoisted(() => ({ resources: [] as BannerResource[] }));
|
||||
@@ -23,43 +18,41 @@ vi.mock('@/utils/logger', () => ({
|
||||
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const DISMISSED_KEY = 'pulse-github-star-dismissed';
|
||||
const FIRST_SEEN_KEY = 'pulse-github-star-first-seen';
|
||||
const SNOOZED_KEY = 'pulse-github-star-snoozed-until';
|
||||
const PROMPT_SHOWN_KEY = 'pulse-github-star-prompt-shown';
|
||||
const ACTIVE_DAYS_KEY = 'pulse-github-star-active-days';
|
||||
const LAST_ACTIVE_DATE_KEY = 'pulse-github-star-last-active-date';
|
||||
const LOW_PRIORITY_NOTICE_OWNER_KEY = 'pulse-low-priority-notice-owner';
|
||||
const LOCAL_DISMISS_BUTTON_CLASS =
|
||||
'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted transition-colors hover:bg-surface-hover hover:text-base-content';
|
||||
const LOCAL_PRIMARY_BUTTON_CLASS =
|
||||
'inline-flex min-h-9 items-center justify-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700';
|
||||
const LOCAL_DEFER_BUTTON_CLASS =
|
||||
'inline-flex min-h-9 items-center justify-center rounded-md px-3 py-2 text-sm text-muted transition-colors hover:bg-surface-hover hover:text-base-content';
|
||||
|
||||
async function renderBanner() {
|
||||
const mod = await import('../GitHubStarBanner');
|
||||
render(() => <mod.GitHubStarBanner />);
|
||||
}
|
||||
|
||||
/** Seed N fake websocket resources into runtime state. */
|
||||
function setResourceCount(n: number) {
|
||||
wsState.resources = Array.from({ length: n }, (_, i) => ({
|
||||
id: `res-${i}`,
|
||||
name: `Resource ${i}`,
|
||||
function setResourceCount(count: number) {
|
||||
wsState.resources = Array.from({ length: count }, (_, index) => ({
|
||||
id: `res-${index}`,
|
||||
name: `Resource ${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tests */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function seedEligibleEngagement() {
|
||||
localStorage.setItem(ACTIVE_DAYS_KEY, '13');
|
||||
localStorage.setItem(LAST_ACTIVE_DATE_KEY, '2026-03-13');
|
||||
}
|
||||
|
||||
describe('GitHubStarBanner', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-03-14T12:00:00Z'));
|
||||
wsState.resources = [];
|
||||
mockInitialDataReceived.mockReturnValue(true);
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -68,175 +61,107 @@ describe('GitHubStarBanner', () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
/* ---------- Visibility: not shown scenarios ---------- */
|
||||
|
||||
it('does not render when there are no resources', async () => {
|
||||
wsState.resources = [];
|
||||
|
||||
it('stays quiet without connected infrastructure', async () => {
|
||||
await renderBanner();
|
||||
|
||||
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument();
|
||||
expect(localStorage.getItem(ACTIVE_DAYS_KEY)).toBe('0');
|
||||
});
|
||||
|
||||
it('keeps the shell banner on websocket runtime state instead of reopening unified resources', () => {
|
||||
expect(gitHubStarBannerSource).toContain('useWebSocket');
|
||||
expect(gitHubStarBannerSource).toContain('initialDataReceived');
|
||||
expect(gitHubStarBannerSource).not.toContain('useResources');
|
||||
});
|
||||
|
||||
it('renders as a non-modal prompt so Assistant remains available', () => {
|
||||
expect(gitHubStarBannerSource).not.toContain('<Dialog');
|
||||
expect(gitHubStarBannerSource).toContain('aria-live="polite"');
|
||||
expect(gitHubStarBannerSource).toContain('z-30');
|
||||
});
|
||||
|
||||
it('routes prompt action chrome through shared Button primitives', () => {
|
||||
expect(gitHubStarBannerSource).toContain('@/components/shared/Button');
|
||||
expect(gitHubStarBannerSource).toContain('<ActionIconButton');
|
||||
expect(gitHubStarBannerSource).toContain('<Button');
|
||||
expect(gitHubStarBannerSource).not.toContain(LOCAL_DISMISS_BUTTON_CLASS);
|
||||
expect(gitHubStarBannerSource).not.toContain(LOCAL_PRIMARY_BUTTON_CLASS);
|
||||
expect(gitHubStarBannerSource).not.toContain(LOCAL_DEFER_BUTTON_CLASS);
|
||||
});
|
||||
|
||||
it('does not render on the first day infrastructure is seen (records first-seen date)', async () => {
|
||||
vi.setSystemTime(new Date('2026-03-01T12:00:00Z'));
|
||||
setResourceCount(3);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
// Should not show prompt
|
||||
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
|
||||
|
||||
// Should have recorded today as first-seen date
|
||||
expect(localStorage.getItem(FIRST_SEEN_KEY)).toBe('2026-03-01');
|
||||
});
|
||||
|
||||
it('does not render before websocket initial data is available', async () => {
|
||||
it('waits for websocket initial data before recording engagement', async () => {
|
||||
mockInitialDataReceived.mockReturnValue(false);
|
||||
setResourceCount(3);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
|
||||
const stored = localStorage.getItem(FIRST_SEEN_KEY);
|
||||
expect(stored === null || stored === '').toBe(true);
|
||||
expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument();
|
||||
expect(localStorage.getItem(ACTIVE_DAYS_KEY)).toBe('0');
|
||||
});
|
||||
|
||||
it('does not render when first-seen date is today (same day)', async () => {
|
||||
vi.setSystemTime(new Date('2026-03-01T12:00:00Z'));
|
||||
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
|
||||
it('counts one distinct active day without showing the prompt', async () => {
|
||||
setResourceCount(3);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(localStorage.getItem(ACTIVE_DAYS_KEY)).toBe('1'));
|
||||
expect(localStorage.getItem(LAST_ACTIVE_DATE_KEY)).toBe('2026-03-14');
|
||||
expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when permanently dismissed', async () => {
|
||||
vi.setSystemTime(new Date('2026-03-05T12:00:00Z'));
|
||||
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
|
||||
localStorage.setItem(DISMISSED_KEY, 'true');
|
||||
it('does not count repeated sessions on the same day', async () => {
|
||||
localStorage.setItem(ACTIVE_DAYS_KEY, '7');
|
||||
localStorage.setItem(LAST_ACTIVE_DATE_KEY, '2026-03-14');
|
||||
setResourceCount(3);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
|
||||
expect(localStorage.getItem(ACTIVE_DAYS_KEY)).toBe('7');
|
||||
expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when within snooze period', async () => {
|
||||
vi.setSystemTime(new Date('2026-03-05T12:00:00Z'));
|
||||
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
|
||||
localStorage.setItem(SNOOZED_KEY, '2026-03-10'); // snoozed until the 10th
|
||||
setResourceCount(3);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
/* ---------- Visibility: shown scenarios ---------- */
|
||||
|
||||
it('renders when returning user has infrastructure (different day than first seen)', async () => {
|
||||
vi.setSystemTime(new Date('2026-03-05T12:00:00Z'));
|
||||
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
|
||||
it('shows one compact prompt after fourteen distinct active days', async () => {
|
||||
seedEligibleEngagement();
|
||||
setResourceCount(3);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument();
|
||||
expect(screen.getByText('Finding Pulse useful?')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders when snooze period has expired', async () => {
|
||||
vi.setSystemTime(new Date('2026-03-15T12:00:00Z'));
|
||||
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
|
||||
localStorage.setItem(SNOOZED_KEY, '2026-03-10'); // expired 5 days ago
|
||||
setResourceCount(3);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- Content ---------- */
|
||||
|
||||
it('displays the expected content when shown', async () => {
|
||||
vi.setSystemTime(new Date('2026-03-05T12:00:00Z'));
|
||||
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
|
||||
setResourceCount(3);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/independent developer/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Starring the project on GitHub helps others discover it.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Star on GitHub')).toBeInTheDocument();
|
||||
expect(screen.getByText('Maybe later')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Close and don't show again")).toBeInTheDocument();
|
||||
expect(screen.queryByText('Maybe later')).not.toBeInTheDocument();
|
||||
expect(localStorage.getItem(ACTIVE_DAYS_KEY)).toBe('14');
|
||||
expect(localStorage.getItem(PROMPT_SHOWN_KEY)).toBe('true');
|
||||
});
|
||||
|
||||
/* ---------- Dismiss (X button) ---------- */
|
||||
|
||||
it('hides the prompt and persists dismissal when the X button is clicked', async () => {
|
||||
vi.setSystemTime(new Date('2026-03-05T12:00:00Z'));
|
||||
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
|
||||
it('never reopens after its single lifetime appearance', async () => {
|
||||
localStorage.setItem(PROMPT_SHOWN_KEY, 'true');
|
||||
localStorage.setItem(ACTIVE_DAYS_KEY, '14');
|
||||
localStorage.setItem(LAST_ACTIVE_DATE_KEY, '2026-03-13');
|
||||
setResourceCount(3);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const closeBtn = screen.getByLabelText("Close and don't show again");
|
||||
fireEvent.click(closeBtn);
|
||||
it('stays quiet when another low-priority notice owns the session', async () => {
|
||||
sessionStorage.setItem(LOW_PRIORITY_NOTICE_OWNER_KEY, 'release-update');
|
||||
seedEligibleEngagement();
|
||||
setResourceCount(3);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
await waitFor(() => expect(localStorage.getItem(ACTIVE_DAYS_KEY)).toBe('14'));
|
||||
expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument();
|
||||
expect(localStorage.getItem(PROMPT_SHOWN_KEY)).not.toBe('true');
|
||||
});
|
||||
|
||||
it('permanently dismisses the prompt from the close action', async () => {
|
||||
seedEligibleEngagement();
|
||||
setResourceCount(3);
|
||||
|
||||
await renderBanner();
|
||||
await waitFor(() => expect(screen.getByText('Finding Pulse useful?')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Close and don't show again"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(localStorage.getItem(DISMISSED_KEY)).toBe('true');
|
||||
});
|
||||
|
||||
/* ---------- Star on GitHub ---------- */
|
||||
|
||||
it('opens GitHub repo, dismisses permanently on star click', async () => {
|
||||
vi.setSystemTime(new Date('2026-03-05T12:00:00Z'));
|
||||
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
|
||||
it('opens GitHub and permanently dismisses on the star action', async () => {
|
||||
seedEligibleEngagement();
|
||||
setResourceCount(3);
|
||||
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Star on GitHub')).toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('Star on GitHub')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByText('Star on GitHub'));
|
||||
|
||||
@@ -245,119 +170,26 @@ describe('GitHubStarBanner', () => {
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Permanent dismissal
|
||||
expect(localStorage.getItem(DISMISSED_KEY)).toBe('true');
|
||||
});
|
||||
|
||||
/* ---------- Maybe later (snooze) ---------- */
|
||||
|
||||
it('hides the prompt and sets a 7-day snooze when "Maybe later" is clicked', async () => {
|
||||
vi.setSystemTime(new Date('2026-03-05T12:00:00Z'));
|
||||
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
|
||||
setResourceCount(3);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Maybe later')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Maybe later'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should snooze for 7 days from today
|
||||
expect(localStorage.getItem(SNOOZED_KEY)).toBe('2026-03-12');
|
||||
|
||||
// Should NOT permanently dismiss
|
||||
expect(localStorage.getItem(DISMISSED_KEY)).not.toBe('true');
|
||||
it('uses runtime state and remains a non-blocking app-shell prompt', () => {
|
||||
expect(gitHubStarBannerSource).toContain('useWebSocket');
|
||||
expect(gitHubStarBannerSource).toContain('initialDataReceived');
|
||||
expect(gitHubStarBannerSource).not.toContain('useResources');
|
||||
expect(gitHubStarBannerSource).not.toContain('<Dialog');
|
||||
expect(gitHubStarBannerSource).toContain('dialogStackHasBlockingDialog');
|
||||
expect(gitHubStarBannerSource).toContain('reserveLowPriorityNoticeSession');
|
||||
expect(gitHubStarBannerSource).toContain('aria-live="polite"');
|
||||
expect(gitHubStarBannerSource).toContain('z-30');
|
||||
});
|
||||
|
||||
it('snoozes on Escape without registering as a blocking dialog', async () => {
|
||||
vi.setSystemTime(new Date('2026-03-05T12:00:00Z'));
|
||||
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
|
||||
setResourceCount(3);
|
||||
|
||||
expect(dialogStackHasBlockingDialog()).toBe(false);
|
||||
await renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(dialogStackHasBlockingDialog()).toBe(false);
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(dialogStackHasBlockingDialog()).toBe(false);
|
||||
expect(localStorage.getItem(SNOOZED_KEY)).toBe('2026-03-12');
|
||||
expect(localStorage.getItem(DISMISSED_KEY)).not.toBe('true');
|
||||
});
|
||||
|
||||
/* ---------- Edge cases ---------- */
|
||||
|
||||
it('renders on the exact snooze expiry date (snooze date == today)', async () => {
|
||||
vi.setSystemTime(new Date('2026-03-10T12:00:00Z'));
|
||||
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
|
||||
localStorage.setItem(SNOOZED_KEY, '2026-03-10'); // today === snooze date, so today < snooze is false
|
||||
setResourceCount(3);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write first-seen date when there are no resources', async () => {
|
||||
vi.setSystemTime(new Date('2026-03-01T12:00:00Z'));
|
||||
wsState.resources = [];
|
||||
|
||||
await renderBanner();
|
||||
|
||||
// The localStorage signal initializes with '' and syncs it, but the
|
||||
// component never sets it to a real date when there are no resources.
|
||||
const stored = localStorage.getItem(FIRST_SEEN_KEY);
|
||||
expect(stored === null || stored === '').toBe(true);
|
||||
});
|
||||
|
||||
it('stays dismissed across re-renders after clicking X', async () => {
|
||||
vi.setSystemTime(new Date('2026-03-05T12:00:00Z'));
|
||||
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
|
||||
setResourceCount(3);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Close and don't show again"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify localStorage has dismissed flag persisted
|
||||
expect(localStorage.getItem(DISMISSED_KEY)).toBe('true');
|
||||
|
||||
// Clean up and re-render — should stay hidden due to persisted dismissal
|
||||
cleanup();
|
||||
await renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
|
||||
});
|
||||
it('routes prompt actions through shared Button primitives', () => {
|
||||
expect(gitHubStarBannerSource).toContain('@/components/shared/Button');
|
||||
expect(gitHubStarBannerSource).toContain('<ActionIconButton');
|
||||
expect(gitHubStarBannerSource).toContain('<Button');
|
||||
expect(gitHubStarBannerSource).not.toContain(LOCAL_DISMISS_BUTTON_CLASS);
|
||||
expect(gitHubStarBannerSource).not.toContain(LOCAL_PRIMARY_BUTTON_CLASS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -367,8 +199,6 @@ describe('GitHubStarBanner mobile navigation clearance', () => {
|
||||
});
|
||||
|
||||
it('does not keep its own copy of the bar height', () => {
|
||||
// This banner was one of five sites hardcoding 5rem for a bar that
|
||||
// measures ~45px. The bar publishes its measured height instead.
|
||||
expect(gitHubStarBannerSource).not.toMatch(/5rem\s*\+\s*env\(safe-area-inset-bottom/);
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ describe('WhatsNewCard', () => {
|
||||
getReleaseNotesMock.mockReset();
|
||||
navigateMock.mockReset();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -126,8 +127,9 @@ describe('WhatsNewCard', () => {
|
||||
expect(screen.queryByTestId('telemetry-payload-update-notice')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the categorized changelog after the running release changes', async () => {
|
||||
it('announces the running release without opening a blocking dialog', async () => {
|
||||
localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.0.5');
|
||||
localStorage.setItem(STORAGE_KEYS.TELEMETRY_PAYLOAD_NOTICE_SEEN, '2');
|
||||
versionInfoMock.mockReturnValue({
|
||||
version: '6.1.0-rc.1',
|
||||
isDevelopment: false,
|
||||
@@ -155,8 +157,18 @@ describe('WhatsNewCard', () => {
|
||||
await renderCard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('whats-new-modal')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('whats-new-notice')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Pulse updated to v6.1.0-rc.1')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: "See what's new in Pulse 6.1.0-rc.1" }),
|
||||
).toBeInTheDocument();
|
||||
expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN)).toBe('6.1.0-rc.1');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: "See what's new in Pulse 6.1.0-rc.1" }));
|
||||
|
||||
expect(screen.queryByTestId('whats-new-notice')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pulse v6.1.0-rc.1 changelog')).toBeInTheDocument();
|
||||
expect(screen.getByText('Added')).toBeInTheDocument();
|
||||
@@ -175,8 +187,9 @@ describe('WhatsNewCard', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('persists dismissal for the current release', async () => {
|
||||
it('dismisses the compact notice without reopening it on reload', async () => {
|
||||
localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.0.5');
|
||||
localStorage.setItem(STORAGE_KEYS.TELEMETRY_PAYLOAD_NOTICE_SEEN, '2');
|
||||
versionInfoMock.mockReturnValue({
|
||||
version: '6.1.0-rc.1',
|
||||
isDevelopment: false,
|
||||
@@ -190,14 +203,48 @@ describe('WhatsNewCard', () => {
|
||||
});
|
||||
|
||||
await renderCard();
|
||||
await waitFor(() => expect(screen.getByText('Got it')).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByTestId('whats-new-notice')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByText('Got it'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dismiss update notice' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('whats-new-modal')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('whats-new-notice')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN)).toBe('6.1.0-rc.1');
|
||||
|
||||
cleanup();
|
||||
await renderCard();
|
||||
|
||||
expect(screen.queryByTestId('whats-new-notice')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the release notice quiet when the telemetry disclosure owns the session', async () => {
|
||||
localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.0.5');
|
||||
versionInfoMock.mockReturnValue({
|
||||
version: '6.1.0-rc.1',
|
||||
isDevelopment: false,
|
||||
isSourceBuild: false,
|
||||
});
|
||||
getReleaseNotesMock.mockResolvedValue({
|
||||
version: '6.1.0-rc.1',
|
||||
releaseNotes: '## Improved\n- Faster resource updates.',
|
||||
releaseDate: '2026-07-13T12:00:00Z',
|
||||
isPrerelease: true,
|
||||
});
|
||||
|
||||
await renderCard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('telemetry-payload-update-notice')).toBeInTheDocument();
|
||||
expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN)).toBe('6.1.0-rc.1');
|
||||
});
|
||||
expect(screen.queryByTestId('whats-new-notice')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dismiss telemetry payload update' }));
|
||||
|
||||
expect(screen.queryByTestId('telemetry-payload-update-notice')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('whats-new-notice')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays quiet when a release has only a highlights summary', async () => {
|
||||
|
||||
@@ -252,4 +252,12 @@ describe('post-update telemetry disclosure', () => {
|
||||
expect(whatsNewCardSource).toContain("openTelemetrySettings('disable')");
|
||||
expect(whatsNewCardSource).toContain('PRIVACY_DOC_URL');
|
||||
});
|
||||
|
||||
it('keeps release details opt-in and coordinates the automatic notice session', () => {
|
||||
expect(whatsNewCardSource).toContain("reserveLowPriorityNoticeSession('release-update')");
|
||||
expect(whatsNewCardSource).toContain('markVersionSeen(currentVersion);');
|
||||
expect(whatsNewCardSource).toContain('actionLabel="See what\'s new"');
|
||||
expect(whatsNewCardSource).toContain('setDialogVisible(true)');
|
||||
expect(whatsNewCardSource).not.toContain('setDialogVisible(true);\n markVersionSeen');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5130,6 +5130,11 @@ describe('shared primitive guardrails', () => {
|
||||
expect(gitHubStarBannerSource).toContain('@/components/shared/Button');
|
||||
expect(gitHubStarBannerSource).toContain('<ActionIconButton');
|
||||
expect(gitHubStarBannerSource).toContain('<Button');
|
||||
expect(gitHubStarBannerSource).toContain('const ACTIVE_DAYS_BEFORE_PROMPT = 14;');
|
||||
expect(gitHubStarBannerSource).toContain("reserveLowPriorityNoticeSession('github-star')");
|
||||
expect(gitHubStarBannerSource).toContain('GITHUB_STAR_PROMPT_SHOWN');
|
||||
expect(gitHubStarBannerSource).not.toContain('Maybe later');
|
||||
expect(gitHubStarBannerSource).not.toContain('GITHUB_STAR_SNOOZED_UNTIL');
|
||||
expect(gitHubStarBannerSource).not.toContain(
|
||||
'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted transition-colors hover:bg-surface-hover hover:text-base-content',
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
createLocalStorageBooleanSignal,
|
||||
createLocalStorageNumberSignal,
|
||||
createLocalStorageStringSignal,
|
||||
reserveLowPriorityNoticeSession,
|
||||
SESSION_STORAGE_KEYS,
|
||||
STORAGE_KEYS,
|
||||
} from '@/utils/localStorage';
|
||||
|
||||
@@ -62,6 +64,7 @@ function dispatchStorageEvent(key: string, newValue: string | null, storageArea:
|
||||
describe('localStorage signals', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -83,7 +86,23 @@ describe('localStorage signals', () => {
|
||||
it('exposes a broad set of keys across feature areas', () => {
|
||||
expect(Object.keys(STORAGE_KEYS).length).toBeGreaterThan(40);
|
||||
expect(STORAGE_KEYS.WORKLOADS_SEARCH_HISTORY).toBe('workloadsSearchHistory');
|
||||
expect(STORAGE_KEYS.GITHUB_STAR_SNOOZED_UNTIL).toBe('pulse-github-star-snoozed-until');
|
||||
expect(STORAGE_KEYS.GITHUB_STAR_ACTIVE_DAYS).toBe('pulse-github-star-active-days');
|
||||
expect(STORAGE_KEYS.GITHUB_STAR_PROMPT_SHOWN).toBe('pulse-github-star-prompt-shown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('low-priority notice sessions', () => {
|
||||
it('allows one owner to reserve and reuse the session notice slot', () => {
|
||||
expect(reserveLowPriorityNoticeSession('release-update')).toBe(true);
|
||||
expect(reserveLowPriorityNoticeSession('release-update')).toBe(true);
|
||||
expect(sessionStorage.getItem(SESSION_STORAGE_KEYS.LOW_PRIORITY_NOTICE_OWNER)).toBe(
|
||||
'release-update',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a second low-priority notice owner in the same session', () => {
|
||||
expect(reserveLowPriorityNoticeSession('telemetry-update')).toBe(true);
|
||||
expect(reserveLowPriorityNoticeSession('github-star')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -138,6 +138,30 @@ export function createLocalStorageStringSignal(
|
||||
);
|
||||
}
|
||||
|
||||
export type LowPriorityNoticeOwner = 'github-star' | 'release-update' | 'telemetry-update';
|
||||
|
||||
export const SESSION_STORAGE_KEYS = {
|
||||
LOW_PRIORITY_NOTICE_OWNER: 'pulse-low-priority-notice-owner',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Reserve the single low-priority app-shell notice slot for this browser
|
||||
* session. Informational and promotional prompts use this boundary so a user
|
||||
* never has to clear a sequence of unrelated notices after opening Pulse.
|
||||
*/
|
||||
export function reserveLowPriorityNoticeSession(owner: LowPriorityNoticeOwner): boolean {
|
||||
if (typeof sessionStorage === 'undefined') return true;
|
||||
try {
|
||||
const existingOwner = sessionStorage.getItem(SESSION_STORAGE_KEYS.LOW_PRIORITY_NOTICE_OWNER);
|
||||
if (existingOwner) return existingOwner === owner;
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEYS.LOW_PRIORITY_NOTICE_OWNER, owner);
|
||||
return true;
|
||||
} catch {
|
||||
// Storage-disabled browsers still get the notice's own persistence rules.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a number signal that syncs with localStorage
|
||||
* @param key - The localStorage key
|
||||
@@ -208,8 +232,9 @@ export const STORAGE_KEYS = {
|
||||
|
||||
// GitHub star prompt
|
||||
GITHUB_STAR_DISMISSED: 'pulse-github-star-dismissed',
|
||||
GITHUB_STAR_FIRST_SEEN: 'pulse-github-star-first-seen',
|
||||
GITHUB_STAR_SNOOZED_UNTIL: 'pulse-github-star-snoozed-until',
|
||||
GITHUB_STAR_PROMPT_SHOWN: 'pulse-github-star-prompt-shown',
|
||||
GITHUB_STAR_ACTIVE_DAYS: 'pulse-github-star-active-days',
|
||||
GITHUB_STAR_LAST_ACTIVE_DATE: 'pulse-github-star-last-active-date',
|
||||
|
||||
// Billing / migration guidance
|
||||
AGENT_MIGRATION_NOTICE_DISMISSED: 'pulse-agent-migration-notice-dismissed',
|
||||
|
||||
Reference in New Issue
Block a user