From 85cf94a08ced6d66f7142daf8517be4fdfe21e7d Mon Sep 17 00:00:00 2001 From: Anshul Khandelwal <12948312+k-anshul@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:09:03 +0530 Subject: [PATCH 1/5] feat: Star on github popup for new users --- .../dashboards/workspace/Dashboard.svelte | 16 +- .../github-star/GithubStarButton.spec.ts | 204 ++++++++++++++++++ .../github-star/GithubStarButton.svelte | 171 +++++++++++++++ .../github-star/github-star.svelte.spec.ts | 154 +++++++++++++ .../github-star/github-star.svelte.ts | 129 +++++++++++ .../src/layout/navigation/Footer.svelte | 2 + web-common/src/lib/i18n/messages/en.json | 5 + web-common/src/lib/i18n/messages/es.json | 5 + .../src/metrics/BehaviourEventHandler.ts | 7 + .../metrics/service/BehaviourEventFactory.ts | 18 ++ .../metrics/service/BehaviourEventTypes.ts | 6 + 11 files changed, 714 insertions(+), 3 deletions(-) create mode 100644 web-common/src/features/github-star/GithubStarButton.spec.ts create mode 100644 web-common/src/features/github-star/GithubStarButton.svelte create mode 100644 web-common/src/features/github-star/github-star.svelte.spec.ts create mode 100644 web-common/src/features/github-star/github-star.svelte.ts diff --git a/web-common/src/features/dashboards/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte index cbac3e2f4682..5a8630bda66f 100644 --- a/web-common/src/features/dashboards/workspace/Dashboard.svelte +++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte @@ -12,8 +12,9 @@ import { dynamicHeight } from "@rilldata/web-common/layout/layout-settings.ts"; import { navigationOpen } from "@rilldata/web-common/layout/navigation/Navigation.svelte"; import Resizer from "@rilldata/web-common/layout/Resizer.svelte"; - import { onDestroy } from "svelte"; - import { readable, type Readable } from "svelte/store"; + import { githubStarNudge } from "@rilldata/web-common/features/github-star/github-star.svelte"; + import { onDestroy, onMount } from "svelte"; + import { get, readable, type Readable } from "svelte/store"; import { useExploreState } from "web-common/src/features/dashboards/stores/dashboard-stores"; import { DashboardState_ActivePage } from "../../../proto/gen/rill/ui/v1/dashboard_pb"; import { useRuntimeClient } from "../../../runtime-client/v2"; @@ -57,10 +58,19 @@ dashboardStore, } = StateManagers; - const { cloudDataViewer, readOnly } = featureFlags; + const { adminServer, cloudDataViewer, readOnly } = featureFlags; const timeControlsStore = useTimeControlStore(StateManagers); + onMount(() => { + // A rendered dashboard is the payoff moment that arms the GitHub star nudge. + // The nudge is Rill Developer only, so Cloud and embeds must not even arm it: + // web-admin sets adminServer synchronously during root layout init, and embeds + // reset it to false, hence both checks. `adminServer` is read once here rather + // than tracked, since a dashboard never moves between Cloud and Developer. + if (!isEmbedded && !get(adminServer)) githubStarNudge.armPayoff(); + }); + let exploreContainerWidth: number; let exploreContainerHeight: number; let resizing = false; diff --git a/web-common/src/features/github-star/GithubStarButton.spec.ts b/web-common/src/features/github-star/GithubStarButton.spec.ts new file mode 100644 index 000000000000..63cfc404c907 --- /dev/null +++ b/web-common/src/features/github-star/GithubStarButton.spec.ts @@ -0,0 +1,204 @@ +import { render, screen } from "@testing-library/svelte"; +import { readable } from "svelte/store"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// The metadata query hits the local-only runtime service; stub it so the +// component can read `analyticsEnabled` without a server. +const analyticsEnabled = vi.hoisted(() => ({ value: true })); +vi.mock("@rilldata/web-common/runtime-client/local-service", () => ({ + createLocalServiceGetMetadata: () => + readable({ data: { analyticsEnabled: analyticsEnabled.value } }), +})); + +const fireGithubStarEvent = vi.hoisted(() => vi.fn()); +vi.mock("@rilldata/web-common/metrics/initMetrics", () => ({ + behaviourEvent: { fireGithubStarEvent }, +})); + +import { featureFlags } from "@rilldata/web-common/features/feature-flags"; +import GithubStarButton from "./GithubStarButton.svelte"; +import { GITHUB_STAR_URL, GithubStarNudge } from "./github-star.svelte"; + +/** Lets the auto-open timer and the resulting Svelte update settle. */ +function settle() { + return vi.advanceTimersByTimeAsync(2000); +} + +function renderArmed() { + const nudge = new GithubStarNudge(); + nudge.armPayoff(); + render(GithubStarButton, { props: { nudge } }); + return nudge; +} + +const outsideElements: HTMLElement[] = []; + +/** Stands in for whatever the user was actually working on, e.g. the editor. */ +function renderOutsideElement() { + const element = document.createElement("button"); + document.body.append(element); + outsideElements.push(element); + return element; +} + +describe("GithubStarButton", () => { + beforeEach(() => { + localStorage.clear(); + analyticsEnabled.value = true; + featureFlags.adminServer.resetToDefault(); + vi.useFakeTimers(); + }); + + afterEach(() => { + featureFlags.adminServer.resetToDefault(); + outsideElements.splice(0).forEach((element) => element.remove()); + vi.useRealTimers(); + }); + + it("renders nothing at all on Rill Cloud", async () => { + featureFlags.adminServer.set(true); + const nudge = renderArmed(); + await settle(); + + // Neither the button nor the nudge: this is a Rill Developer feature. + expect(screen.queryByText("Star us on GitHub")).not.toBeInTheDocument(); + expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); + expect(nudge.state.dismissCount).toBe(0); + expect(fireGithubStarEvent).not.toHaveBeenCalled(); + }); + + it("always renders the footer link, pointing straight at the repo", () => { + render(GithubStarButton, { props: { nudge: new GithubStarNudge() } }); + + const link = screen.getByText("Star us on GitHub").closest("a"); + expect(link).toHaveAttribute("href", GITHUB_STAR_URL); + expect(link).toHaveAttribute("target", "_blank"); + }); + + it("does not open unprompted when no payoff has happened", async () => { + render(GithubStarButton, { props: { nudge: new GithubStarNudge() } }); + await settle(); + + expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); + }); + + it("opens unprompted once a payoff has armed it", async () => { + renderArmed(); + await settle(); + + expect(screen.getByText("Enjoying Rill?")).toBeInTheDocument(); + expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-shown"); + }); + + it("does not take the keyboard when the nudge opens", async () => { + const editor = renderOutsideElement(); + editor.focus(); + + renderArmed(); + await settle(); + + expect(screen.getByText("Enjoying Rill?")).toBeInTheDocument(); + expect(document.activeElement).toBe(editor); + }); + + it("lets focus leave the open nudge instead of trapping it", async () => { + const editor = renderOutsideElement(); + renderArmed(); + await settle(); + + editor.focus(); + await settle(); + + // The nudge stays open; it just does not hold the keyboard hostage. + expect(screen.getByText("Enjoying Rill?")).toBeInTheDocument(); + expect(document.activeElement).toBe(editor); + }); + + it("leaves focus where it is when the nudge closes", async () => { + renderArmed(); + await settle(); + + const editor = renderOutsideElement(); + editor.focus(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + await settle(); + + expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); + expect(document.activeElement).toBe(editor); + }); + + it("keeps the button but withholds the nudge when telemetry is disabled", async () => { + analyticsEnabled.value = false; + const nudge = renderArmed(); + await settle(); + + expect(screen.getByText("Star us on GitHub")).toBeInTheDocument(); + expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); + expect(nudge.state.dismissCount).toBe(0); + }); + + it("counts dismissing the nudge with Escape as a soft dismissal", async () => { + const nudge = renderArmed(); + await settle(); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + await settle(); + + // Still armed, but muted: the ask is deferred rather than abandoned. + expect(nudge.state.status).toBe("armed"); + expect(nudge.state.dismissCount).toBe(1); + expect(nudge.state.mutedUntil).toBeGreaterThan(Date.now()); + expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-dismissed"); + }); + + it("retires the nudge when the footer link is clicked, without opening the popover", async () => { + // Armed, and clicked inside the grace period: the pending auto-open must be + // cancelled rather than firing at someone already on their way to the repo. + const nudge = renderArmed(); + screen.getByText("Star us on GitHub").closest("a")!.click(); + await settle(); + + expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); + expect(nudge.state.status).toBe("done"); + expect(nudge.state.dismissCount).toBe(0); + expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-clicked"); + expect(fireGithubStarEvent).not.toHaveBeenCalledWith( + "github-star-dismissed", + ); + }); + + it("records an opt-out as terminal without spending a dismissal", async () => { + const nudge = renderArmed(); + await settle(); + + screen.getByText("Don't show again").click(); + await settle(); + + expect(nudge.state.status).toBe("done"); + expect(nudge.state.dismissCount).toBe(0); + expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-opted-out"); + }); + + it("records the star click as terminal without spending a dismissal", async () => { + const nudge = renderArmed(); + await settle(); + + screen.getByText("Star on GitHub").click(); + await settle(); + + expect(nudge.state.status).toBe("done"); + expect(nudge.state.dismissCount).toBe(0); + expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-clicked"); + }); + + it("does not re-open unprompted after being dismissed", async () => { + renderArmed(); + await settle(); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + await settle(); + await settle(); + + expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); + }); +}); diff --git a/web-common/src/features/github-star/GithubStarButton.svelte b/web-common/src/features/github-star/GithubStarButton.svelte new file mode 100644 index 000000000000..2c84263e33fe --- /dev/null +++ b/web-common/src/features/github-star/GithubStarButton.svelte @@ -0,0 +1,171 @@ + + +{#if !$adminServer} + + + + + + + + {m.github_star_footer_label()} + + + + + + e.preventDefault()} + onCloseAutoFocus={(e: Event) => e.preventDefault()} + > + + + + {m.github_star_title()} + + + {m.github_star_message()} + + + + + + {m.github_star_cta()} + + + {m.github_star_dismiss()} + + + + + +{/if} diff --git a/web-common/src/features/github-star/github-star.svelte.spec.ts b/web-common/src/features/github-star/github-star.svelte.spec.ts new file mode 100644 index 000000000000..167917fdc4c9 --- /dev/null +++ b/web-common/src/features/github-star/github-star.svelte.spec.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GithubStarNudge } from "./github-star.svelte"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const START = new Date("2026-08-04T12:00:00Z").getTime(); + +/** Sets the clock to `days` after the start of the test. */ +function atDay(days: number) { + vi.setSystemTime(START + days * DAY_MS); +} + +describe("GithubStarNudge", () => { + beforeEach(() => { + localStorage.clear(); + vi.useFakeTimers(); + vi.setSystemTime(START); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("stays hidden until a payoff arms it", () => { + const nudge = new GithubStarNudge(); + expect(nudge.visible).toBe(false); + + nudge.armPayoff(); + expect(nudge.visible).toBe(true); + }); + + it("persists the armed state across reloads", () => { + new GithubStarNudge().armPayoff(); + expect(new GithubStarNudge().visible).toBe(true); + }); + + it("treats a star click as terminal", () => { + const nudge = new GithubStarNudge(); + nudge.armPayoff(); + nudge.recordStar(); + expect(nudge.visible).toBe(false); + + // Neither a later payoff nor the passage of time may resurrect it. + nudge.armPayoff(); + expect(nudge.visible).toBe(false); + atDay(365); + expect(new GithubStarNudge().visible).toBe(false); + }); + + it("treats an explicit opt-out as terminal", () => { + const nudge = new GithubStarNudge(); + nudge.armPayoff(); + nudge.recordOptOut(); + + atDay(365); + expect(new GithubStarNudge().visible).toBe(false); + }); + + it("escalates soft dismissals to 30 days, then 90 days, then permanently", () => { + const first = new GithubStarNudge(); + first.armPayoff(); + + first.recordSoftDismiss(); + expect(first.visible).toBe(false); + atDay(29); + expect(new GithubStarNudge().visible).toBe(false); + atDay(31); + expect(new GithubStarNudge().visible).toBe(true); + + const second = new GithubStarNudge(); + second.recordSoftDismiss(); + atDay(31 + 89); + expect(new GithubStarNudge().visible).toBe(false); + atDay(31 + 91); + expect(new GithubStarNudge().visible).toBe(true); + + const third = new GithubStarNudge(); + third.recordSoftDismiss(); + expect(third.state.status).toBe("done"); + atDay(3650); + expect(new GithubStarNudge().visible).toBe(false); + }); + + it("caps an install at three asks", () => { + new GithubStarNudge().armPayoff(); + + let asks = 0; + for (let day = 0; day < 400; day++) { + atDay(day); + const nudge = new GithubStarNudge(); + if (nudge.visible) { + asks++; + nudge.recordSoftDismiss(); + } + } + + expect(asks).toBe(3); + }); + + it("ignores a soft dismissal while muted", () => { + const nudge = new GithubStarNudge(); + nudge.armPayoff(); + nudge.recordSoftDismiss(); + expect(nudge.state.dismissCount).toBe(1); + + // A dismissal that could not have been seen must not advance the backoff. + nudge.recordSoftDismiss(); + expect(nudge.state.dismissCount).toBe(1); + }); + + it("ignores a soft dismissal that was never armed", () => { + const nudge = new GithubStarNudge(); + nudge.recordSoftDismiss(); + + expect(nudge.state.dismissCount).toBe(0); + expect(nudge.state.status).toBe("unarmed"); + }); + + it("does not re-arm on a second payoff once muted", () => { + const nudge = new GithubStarNudge(); + nudge.armPayoff(); + nudge.recordSoftDismiss(); + + nudge.armPayoff(); + expect(nudge.visible).toBe(false); + }); + + it("treats corrupt stored state as unarmed", () => { + localStorage.setItem("rill:github-star", "{not json"); + expect(new GithubStarNudge().visible).toBe(false); + + localStorage.setItem("rill:github-star", JSON.stringify({ nonsense: 1 })); + expect(new GithubStarNudge().visible).toBe(false); + }); + + it("degrades to in-memory when localStorage throws", () => { + const setItem = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new DOMException("denied", "SecurityError"); + }); + const getItem = vi + .spyOn(Storage.prototype, "getItem") + .mockImplementation(() => { + throw new DOMException("denied", "SecurityError"); + }); + + const nudge = new GithubStarNudge(); + expect(() => nudge.armPayoff()).not.toThrow(); + expect(nudge.visible).toBe(true); + + setItem.mockRestore(); + getItem.mockRestore(); + }); +}); diff --git a/web-common/src/features/github-star/github-star.svelte.ts b/web-common/src/features/github-star/github-star.svelte.ts new file mode 100644 index 000000000000..90ebd91f4cbf --- /dev/null +++ b/web-common/src/features/github-star/github-star.svelte.ts @@ -0,0 +1,129 @@ +export const GITHUB_STAR_URL = "https://github.com/rilldata/rill"; + +const STORAGE_KEY = "rill:github-star"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Mute applied after the Nth soft dismiss. + * Running off the end of the schedule is terminal: three ignores is a no. + */ +const MUTE_SCHEDULE_DAYS = [30, 90]; + +export type GithubStarStatus = "unarmed" | "armed" | "done"; + +export interface GithubStarState { + status: GithubStarStatus; + dismissCount: number; + /** While in the future, an armed nudge stays hidden. */ + mutedUntil?: number; +} + +const INITIAL_STATE: GithubStarState = { status: "unarmed", dismissCount: 0 }; + +/** + * Tracks whether to nudge the user to star Rill on GitHub. + * + * The nudge is armed by a payoff moment (a dashboard rendering) rather than by + * install or upgrade: a fresh install has no accumulated value to be happy about, + * and Rill ships patches every few days, so an upgrade trigger would nag the most + * engaged users hardest. + * + * Dismissal escalates rather than muting for a fixed period. An "X" click is + * ambiguous between "not now" and "no", and the client cannot tell which, so a + * flat mute would re-ask forever. Escalating to permanent caps an install at + * three asks. + */ +export class GithubStarNudge { + #state = $state(INITIAL_STATE); + + constructor() { + this.#state = readState(); + } + + get state() { + return this.#state; + } + + /** + * Whether the nudge should be shown right now. + * Recomputed whenever the state changes; mutes last 30+ days, so re-reading on + * each app load is ample granularity for expiry. + */ + get visible() { + return isVisible(this.#state); + } + + /** Called when the user reaches a payoff moment: a dashboard rendered. */ + armPayoff() { + if (this.#state.status !== "unarmed") return; + this.#commit({ ...this.#state, status: "armed" }); + } + + /** The user starred. Terminal. */ + recordStar() { + this.#commit({ ...this.#state, status: "done" }); + } + + /** The user clicked "Don't show again". Terminal. */ + recordOptOut() { + this.#commit({ ...this.#state, status: "done" }); + } + + /** + * Any other exit: X, click-outside, timeout, navigating away. These are + * indistinguishable from the client, and not counting silent ignores would let + * engaged users be re-asked every 30 days with no backoff. + */ + recordSoftDismiss() { + if (!isVisible(this.#state)) return; + + const dismissCount = this.#state.dismissCount + 1; + const muteDays = MUTE_SCHEDULE_DAYS[dismissCount - 1]; + if (muteDays === undefined) { + this.#commit({ ...this.#state, status: "done", dismissCount }); + return; + } + this.#commit({ + ...this.#state, + dismissCount, + mutedUntil: Date.now() + muteDays * DAY_MS, + }); + } + + #commit(next: GithubStarState) { + this.#state = next; + writeState(next); + } +} + +export function isVisible(state: GithubStarState, now = Date.now()) { + if (state.status !== "armed") return false; + return !state.mutedUntil || state.mutedUntil <= now; +} + +function readState(): GithubStarState { + try { + // Accessing localStorage can throw rather than return null: a sandboxed or + // storage-partitioned iframe raises a SecurityError, and it is absent on the server. + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return INITIAL_STATE; + const parsed = JSON.parse(raw) as GithubStarState; + if (!parsed || typeof parsed.status !== "string") return INITIAL_STATE; + return { ...INITIAL_STATE, ...parsed }; + } catch { + // Corrupt or unreadable state degrades to "unarmed" rather than throwing. + // The worst case is one extra ask, which the backoff then bounds. + return INITIAL_STATE; + } +} + +function writeState(state: GithubStarState) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + // no-op: nudge degrades to in-memory only + } +} + +export const githubStarNudge = new GithubStarNudge(); diff --git a/web-common/src/layout/navigation/Footer.svelte b/web-common/src/layout/navigation/Footer.svelte index 050d80f86eb7..68e942f3e668 100644 --- a/web-common/src/layout/navigation/Footer.svelte +++ b/web-common/src/layout/navigation/Footer.svelte @@ -10,6 +10,7 @@ import { fly } from "svelte/transition"; import { createLocalServiceGetMetadata } from "@rilldata/web-common/runtime-client/local-service"; import RuntimeTrafficLights from "@rilldata/web-common/features/entity-management/RuntimeTrafficLights.svelte"; + import GithubStarButton from "@rilldata/web-common/features/github-star/GithubStarButton.svelte"; const metadataQuery = createLocalServiceGetMetadata(); @@ -51,6 +52,7 @@ {/each} + Date: Wed, 5 Aug 2026 10:53:23 +0530 Subject: [PATCH 2/5] self review --- .../dashboards/workspace/Dashboard.svelte | 7 +-- .../github-star/GithubStarButton.spec.ts | 34 ----------- .../github-star/GithubStarButton.svelte | 26 +-------- .../github-star/github-star.svelte.ts | 56 ++++++++----------- .../src/metrics/BehaviourEventHandler.ts | 7 --- .../metrics/service/BehaviourEventFactory.ts | 18 ------ .../metrics/service/BehaviourEventTypes.ts | 6 -- 7 files changed, 27 insertions(+), 127 deletions(-) diff --git a/web-common/src/features/dashboards/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte index 5a8630bda66f..2e78e25f02a6 100644 --- a/web-common/src/features/dashboards/workspace/Dashboard.svelte +++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte @@ -63,11 +63,8 @@ const timeControlsStore = useTimeControlStore(StateManagers); onMount(() => { - // A rendered dashboard is the payoff moment that arms the GitHub star nudge. - // The nudge is Rill Developer only, so Cloud and embeds must not even arm it: - // web-admin sets adminServer synchronously during root layout init, and embeds - // reset it to false, hence both checks. `adminServer` is read once here rather - // than tracked, since a dashboard never moves between Cloud and Developer. + // Github star nudge is Rill developer only. + // Nudge on dashboard render. if (!isEmbedded && !get(adminServer)) githubStarNudge.armPayoff(); }); diff --git a/web-common/src/features/github-star/GithubStarButton.spec.ts b/web-common/src/features/github-star/GithubStarButton.spec.ts index 63cfc404c907..f0ece4c85b3b 100644 --- a/web-common/src/features/github-star/GithubStarButton.spec.ts +++ b/web-common/src/features/github-star/GithubStarButton.spec.ts @@ -1,20 +1,6 @@ import { render, screen } from "@testing-library/svelte"; -import { readable } from "svelte/store"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -// The metadata query hits the local-only runtime service; stub it so the -// component can read `analyticsEnabled` without a server. -const analyticsEnabled = vi.hoisted(() => ({ value: true })); -vi.mock("@rilldata/web-common/runtime-client/local-service", () => ({ - createLocalServiceGetMetadata: () => - readable({ data: { analyticsEnabled: analyticsEnabled.value } }), -})); - -const fireGithubStarEvent = vi.hoisted(() => vi.fn()); -vi.mock("@rilldata/web-common/metrics/initMetrics", () => ({ - behaviourEvent: { fireGithubStarEvent }, -})); - import { featureFlags } from "@rilldata/web-common/features/feature-flags"; import GithubStarButton from "./GithubStarButton.svelte"; import { GITHUB_STAR_URL, GithubStarNudge } from "./github-star.svelte"; @@ -44,7 +30,6 @@ function renderOutsideElement() { describe("GithubStarButton", () => { beforeEach(() => { localStorage.clear(); - analyticsEnabled.value = true; featureFlags.adminServer.resetToDefault(); vi.useFakeTimers(); }); @@ -64,7 +49,6 @@ describe("GithubStarButton", () => { expect(screen.queryByText("Star us on GitHub")).not.toBeInTheDocument(); expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); expect(nudge.state.dismissCount).toBe(0); - expect(fireGithubStarEvent).not.toHaveBeenCalled(); }); it("always renders the footer link, pointing straight at the repo", () => { @@ -87,7 +71,6 @@ describe("GithubStarButton", () => { await settle(); expect(screen.getByText("Enjoying Rill?")).toBeInTheDocument(); - expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-shown"); }); it("does not take the keyboard when the nudge opens", async () => { @@ -127,16 +110,6 @@ describe("GithubStarButton", () => { expect(document.activeElement).toBe(editor); }); - it("keeps the button but withholds the nudge when telemetry is disabled", async () => { - analyticsEnabled.value = false; - const nudge = renderArmed(); - await settle(); - - expect(screen.getByText("Star us on GitHub")).toBeInTheDocument(); - expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); - expect(nudge.state.dismissCount).toBe(0); - }); - it("counts dismissing the nudge with Escape as a soft dismissal", async () => { const nudge = renderArmed(); await settle(); @@ -148,7 +121,6 @@ describe("GithubStarButton", () => { expect(nudge.state.status).toBe("armed"); expect(nudge.state.dismissCount).toBe(1); expect(nudge.state.mutedUntil).toBeGreaterThan(Date.now()); - expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-dismissed"); }); it("retires the nudge when the footer link is clicked, without opening the popover", async () => { @@ -161,10 +133,6 @@ describe("GithubStarButton", () => { expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); expect(nudge.state.status).toBe("done"); expect(nudge.state.dismissCount).toBe(0); - expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-clicked"); - expect(fireGithubStarEvent).not.toHaveBeenCalledWith( - "github-star-dismissed", - ); }); it("records an opt-out as terminal without spending a dismissal", async () => { @@ -176,7 +144,6 @@ describe("GithubStarButton", () => { expect(nudge.state.status).toBe("done"); expect(nudge.state.dismissCount).toBe(0); - expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-opted-out"); }); it("records the star click as terminal without spending a dismissal", async () => { @@ -188,7 +155,6 @@ describe("GithubStarButton", () => { expect(nudge.state.status).toBe("done"); expect(nudge.state.dismissCount).toBe(0); - expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-clicked"); }); it("does not re-open unprompted after being dismissed", async () => { diff --git a/web-common/src/features/github-star/GithubStarButton.svelte b/web-common/src/features/github-star/GithubStarButton.svelte index 2c84263e33fe..0e570206ea06 100644 --- a/web-common/src/features/github-star/GithubStarButton.svelte +++ b/web-common/src/features/github-star/GithubStarButton.svelte @@ -7,9 +7,6 @@ } from "@rilldata/web-common/components/popover"; import { featureFlags } from "@rilldata/web-common/features/feature-flags"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; - import { behaviourEvent } from "@rilldata/web-common/metrics/initMetrics"; - import { BehaviourEventAction } from "@rilldata/web-common/metrics/service/BehaviourEventTypes"; - import { createLocalServiceGetMetadata } from "@rilldata/web-common/runtime-client/local-service"; import { GITHUB_STAR_URL, githubStarNudge, @@ -28,8 +25,6 @@ // relying on a `showFooterLinks={false}` in an unrelated layout. const { adminServer } = featureFlags; - const metadataQuery = createLocalServiceGetMetadata(); - let open = $state(false); /** * Whether a nudge is outstanding, i.e. the popover opened by itself and the user @@ -42,25 +37,15 @@ /** The popover has no trigger, so it anchors to the footer link instead. */ let anchor = $state(null); - // A user who ran `rill telemetry disable` has signalled that they do not want to - // be marketed to. They keep the button; they do not get the unprompted nudge. - const analyticsEnabled = $derived( - $metadataQuery.data?.analyticsEnabled !== false, - ); - // Opening a popover on a timer has no reactive equivalent, so this is one of the // cases where an effect is the right tool. $effect(() => { - if ($adminServer || autoOpened || !nudge.visible || !analyticsEnabled) - return; + if ($adminServer || autoOpened || !nudge.visible) return; const timeout = setTimeout(() => { autoOpened = true; nudging = true; open = true; - void behaviourEvent?.fireGithubStarEvent( - BehaviourEventAction.GithubStarShown, - ); }, AUTO_OPEN_DELAY_MS); return () => clearTimeout(timeout); @@ -73,27 +58,18 @@ // silent ignores would re-ask engaged users at every mute expiry. nudging = false; nudge.recordSoftDismiss(); - void behaviourEvent?.fireGithubStarEvent( - BehaviourEventAction.GithubStarDismissed, - ); } // Shared by the footer link and the popover's primary action: both send the user // to the repo, so both retire the nudge. function star() { nudge.recordStar(); - void behaviourEvent?.fireGithubStarEvent( - BehaviourEventAction.GithubStarClicked, - ); nudging = false; open = false; } function optOut() { nudge.recordOptOut(); - void behaviourEvent?.fireGithubStarEvent( - BehaviourEventAction.GithubStarOptedOut, - ); nudging = false; open = false; } diff --git a/web-common/src/features/github-star/github-star.svelte.ts b/web-common/src/features/github-star/github-star.svelte.ts index 90ebd91f4cbf..29c0d67600a5 100644 --- a/web-common/src/features/github-star/github-star.svelte.ts +++ b/web-common/src/features/github-star/github-star.svelte.ts @@ -23,26 +23,18 @@ const INITIAL_STATE: GithubStarState = { status: "unarmed", dismissCount: 0 }; /** * Tracks whether to nudge the user to star Rill on GitHub. - * - * The nudge is armed by a payoff moment (a dashboard rendering) rather than by - * install or upgrade: a fresh install has no accumulated value to be happy about, - * and Rill ships patches every few days, so an upgrade trigger would nag the most - * engaged users hardest. - * - * Dismissal escalates rather than muting for a fixed period. An "X" click is - * ambiguous between "not now" and "no", and the client cannot tell which, so a - * flat mute would re-ask forever. Escalating to permanent caps an install at - * three asks. + * The nudge is armed by a dashboard render. + * Dismissal escalates rather than muting for a fixed period. After three soft dismissals, the nudge is retired. */ export class GithubStarNudge { - #state = $state(INITIAL_STATE); + private currentState = $state(INITIAL_STATE); - constructor() { - this.#state = readState(); + public constructor() { + this.currentState = readState(); } - get state() { - return this.#state; + public get state() { + return this.currentState; } /** @@ -50,24 +42,24 @@ export class GithubStarNudge { * Recomputed whenever the state changes; mutes last 30+ days, so re-reading on * each app load is ample granularity for expiry. */ - get visible() { - return isVisible(this.#state); + public get visible() { + return isVisible(this.currentState); } /** Called when the user reaches a payoff moment: a dashboard rendered. */ - armPayoff() { - if (this.#state.status !== "unarmed") return; - this.#commit({ ...this.#state, status: "armed" }); + public armPayoff() { + if (this.currentState.status !== "unarmed") return; + this.commit({ ...this.currentState, status: "armed" }); } /** The user starred. Terminal. */ - recordStar() { - this.#commit({ ...this.#state, status: "done" }); + public recordStar() { + this.commit({ ...this.currentState, status: "done" }); } /** The user clicked "Don't show again". Terminal. */ - recordOptOut() { - this.#commit({ ...this.#state, status: "done" }); + public recordOptOut() { + this.commit({ ...this.currentState, status: "done" }); } /** @@ -75,24 +67,24 @@ export class GithubStarNudge { * indistinguishable from the client, and not counting silent ignores would let * engaged users be re-asked every 30 days with no backoff. */ - recordSoftDismiss() { - if (!isVisible(this.#state)) return; + public recordSoftDismiss() { + if (!isVisible(this.currentState)) return; - const dismissCount = this.#state.dismissCount + 1; + const dismissCount = this.currentState.dismissCount + 1; const muteDays = MUTE_SCHEDULE_DAYS[dismissCount - 1]; if (muteDays === undefined) { - this.#commit({ ...this.#state, status: "done", dismissCount }); + this.commit({ ...this.currentState, status: "done", dismissCount }); return; } - this.#commit({ - ...this.#state, + this.commit({ + ...this.currentState, dismissCount, mutedUntil: Date.now() + muteDays * DAY_MS, }); } - #commit(next: GithubStarState) { - this.#state = next; + private commit(next: GithubStarState) { + this.currentState = next; writeState(next); } } diff --git a/web-common/src/metrics/BehaviourEventHandler.ts b/web-common/src/metrics/BehaviourEventHandler.ts index 1ed3f663fda2..923c2dee7f6b 100644 --- a/web-common/src/metrics/BehaviourEventHandler.ts +++ b/web-common/src/metrics/BehaviourEventHandler.ts @@ -99,13 +99,6 @@ export class BehaviourEventHandler { ]); } - public fireGithubStarEvent(action: BehaviourEventAction) { - return this.metricsService.dispatch("githubStarEvent", [ - this.commonUserMetrics, - action, - ]); - } - public fireGithubIntentEvent( action: BehaviourEventAction, githubFields?: GithubEventFields, diff --git a/web-common/src/metrics/service/BehaviourEventFactory.ts b/web-common/src/metrics/service/BehaviourEventFactory.ts index aa9e9bcb7633..9ac5dd60c521 100644 --- a/web-common/src/metrics/service/BehaviourEventFactory.ts +++ b/web-common/src/metrics/service/BehaviourEventFactory.ts @@ -126,24 +126,6 @@ export class BehaviourEventFactory extends MetricsEventFactory { return event; } - public githubStarEvent( - commonFields: CommonFields, - commonUserFields: CommonUserFields, - action: BehaviourEventAction, - ): BehaviourEvent { - const event = this.getBaseMetricsEvent( - "behavioral", - action, - commonFields, - commonUserFields, - ) as BehaviourEvent; - event.action = action; - event.medium = BehaviourEventMedium.Button; - event.space = MetricsEventSpace.LeftPanel; - event.screen_name = MetricsEventScreenName.Unknown; - return event; - } - public githubIntent( commonFields: CommonFields, commonUserFields: CommonUserFields, diff --git a/web-common/src/metrics/service/BehaviourEventTypes.ts b/web-common/src/metrics/service/BehaviourEventTypes.ts index 9aaf9d3038f1..62510341cf26 100644 --- a/web-common/src/metrics/service/BehaviourEventTypes.ts +++ b/web-common/src/metrics/service/BehaviourEventTypes.ts @@ -32,12 +32,6 @@ export enum BehaviourEventAction { GithubConnectFailure = "ghconnected-failure", GithubDisconnect = "ghconnected-disconnect", - // GitHub star nudge actions - GithubStarShown = "github-star-shown", - GithubStarClicked = "github-star-clicked", - GithubStarDismissed = "github-star-dismissed", - GithubStarOptedOut = "github-star-opted-out", - // Welcome/Connector actions AddDataIntent = "add-data-intent", WelcomePageViewed = "welcome-page-viewed", From f60929f4108820eb890c9d252f32ff6f11fc5431 Mon Sep 17 00:00:00 2001 From: Anshul Khandelwal <12948312+k-anshul@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:26:41 +0530 Subject: [PATCH 3/5] dismissal logic change --- .../github-star/GithubStarButton.spec.ts | 10 ++-- .../github-star/GithubStarButton.svelte | 50 +++++++++++++++---- .../github-star/github-star.svelte.spec.ts | 40 ++++++--------- .../github-star/github-star.svelte.ts | 31 +++--------- web-common/src/lib/i18n/messages/en.json | 2 +- web-common/src/lib/i18n/messages/es.json | 2 +- 6 files changed, 69 insertions(+), 66 deletions(-) diff --git a/web-common/src/features/github-star/GithubStarButton.spec.ts b/web-common/src/features/github-star/GithubStarButton.spec.ts index f0ece4c85b3b..463707710a85 100644 --- a/web-common/src/features/github-star/GithubStarButton.spec.ts +++ b/web-common/src/features/github-star/GithubStarButton.spec.ts @@ -48,7 +48,8 @@ describe("GithubStarButton", () => { // Neither the button nor the nudge: this is a Rill Developer feature. expect(screen.queryByText("Star us on GitHub")).not.toBeInTheDocument(); expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); - expect(nudge.state.dismissCount).toBe(0); + expect(nudge.state.status).toBe("armed"); + expect(nudge.state.mutedUntil).toBeUndefined(); }); it("always renders the footer link, pointing straight at the repo", () => { @@ -119,7 +120,6 @@ describe("GithubStarButton", () => { // Still armed, but muted: the ask is deferred rather than abandoned. expect(nudge.state.status).toBe("armed"); - expect(nudge.state.dismissCount).toBe(1); expect(nudge.state.mutedUntil).toBeGreaterThan(Date.now()); }); @@ -132,7 +132,7 @@ describe("GithubStarButton", () => { expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); expect(nudge.state.status).toBe("done"); - expect(nudge.state.dismissCount).toBe(0); + expect(nudge.state.mutedUntil).toBeUndefined(); }); it("records an opt-out as terminal without spending a dismissal", async () => { @@ -143,7 +143,7 @@ describe("GithubStarButton", () => { await settle(); expect(nudge.state.status).toBe("done"); - expect(nudge.state.dismissCount).toBe(0); + expect(nudge.state.mutedUntil).toBeUndefined(); }); it("records the star click as terminal without spending a dismissal", async () => { @@ -154,7 +154,7 @@ describe("GithubStarButton", () => { await settle(); expect(nudge.state.status).toBe("done"); - expect(nudge.state.dismissCount).toBe(0); + expect(nudge.state.mutedUntil).toBeUndefined(); }); it("does not re-open unprompted after being dismissed", async () => { diff --git a/web-common/src/features/github-star/GithubStarButton.svelte b/web-common/src/features/github-star/GithubStarButton.svelte index 0e570206ea06..317693f9339a 100644 --- a/web-common/src/features/github-star/GithubStarButton.svelte +++ b/web-common/src/features/github-star/GithubStarButton.svelte @@ -111,24 +111,34 @@ customAnchor={anchor} align="start" side="top" - class="w-[260px] p-4" + sideOffset={8} + class="github-star-popover w-[280px] overflow-hidden border-primary-200 bg-surface-overlay p-0 shadow-xl" role="status" trapFocus={false} onOpenAutoFocus={(e: Event) => e.preventDefault()} onCloseAutoFocus={(e: Event) => e.preventDefault()} > - - - - {m.github_star_title()} - - - {m.github_star_message()} - + + + + + + + + + {m.github_star_title()} + + + {m.github_star_message()} + + {/if} + + diff --git a/web-common/src/features/github-star/github-star.svelte.spec.ts b/web-common/src/features/github-star/github-star.svelte.spec.ts index 167917fdc4c9..ee211facfff9 100644 --- a/web-common/src/features/github-star/github-star.svelte.spec.ts +++ b/web-common/src/features/github-star/github-star.svelte.spec.ts @@ -55,32 +55,20 @@ describe("GithubStarNudge", () => { expect(new GithubStarNudge().visible).toBe(false); }); - it("escalates soft dismissals to 30 days, then 90 days, then permanently", () => { - const first = new GithubStarNudge(); - first.armPayoff(); - - first.recordSoftDismiss(); - expect(first.visible).toBe(false); - atDay(29); - expect(new GithubStarNudge().visible).toBe(false); - atDay(31); - expect(new GithubStarNudge().visible).toBe(true); + it("mutes a soft dismissal until the following day", () => { + const nudge = new GithubStarNudge(); + nudge.armPayoff(); + nudge.recordSoftDismiss(); - const second = new GithubStarNudge(); - second.recordSoftDismiss(); - atDay(31 + 89); + expect(nudge.state.status).toBe("armed"); + expect(nudge.visible).toBe(false); + atDay(0.99); expect(new GithubStarNudge().visible).toBe(false); - atDay(31 + 91); + atDay(1); expect(new GithubStarNudge().visible).toBe(true); - - const third = new GithubStarNudge(); - third.recordSoftDismiss(); - expect(third.state.status).toBe("done"); - atDay(3650); - expect(new GithubStarNudge().visible).toBe(false); }); - it("caps an install at three asks", () => { + it("continues asking daily until the user stars or opts out", () => { new GithubStarNudge().armPayoff(); let asks = 0; @@ -93,25 +81,25 @@ describe("GithubStarNudge", () => { } } - expect(asks).toBe(3); + expect(asks).toBe(400); + expect(new GithubStarNudge().state.status).toBe("armed"); }); it("ignores a soft dismissal while muted", () => { const nudge = new GithubStarNudge(); nudge.armPayoff(); nudge.recordSoftDismiss(); - expect(nudge.state.dismissCount).toBe(1); + const mutedUntil = nudge.state.mutedUntil; - // A dismissal that could not have been seen must not advance the backoff. + // A dismissal that could not have been seen must not extend the mute. nudge.recordSoftDismiss(); - expect(nudge.state.dismissCount).toBe(1); + expect(nudge.state.mutedUntil).toBe(mutedUntil); }); it("ignores a soft dismissal that was never armed", () => { const nudge = new GithubStarNudge(); nudge.recordSoftDismiss(); - expect(nudge.state.dismissCount).toBe(0); expect(nudge.state.status).toBe("unarmed"); }); diff --git a/web-common/src/features/github-star/github-star.svelte.ts b/web-common/src/features/github-star/github-star.svelte.ts index 29c0d67600a5..0d60d74fb622 100644 --- a/web-common/src/features/github-star/github-star.svelte.ts +++ b/web-common/src/features/github-star/github-star.svelte.ts @@ -4,27 +4,20 @@ const STORAGE_KEY = "rill:github-star"; const DAY_MS = 24 * 60 * 60 * 1000; -/** - * Mute applied after the Nth soft dismiss. - * Running off the end of the schedule is terminal: three ignores is a no. - */ -const MUTE_SCHEDULE_DAYS = [30, 90]; - export type GithubStarStatus = "unarmed" | "armed" | "done"; export interface GithubStarState { status: GithubStarStatus; - dismissCount: number; /** While in the future, an armed nudge stays hidden. */ mutedUntil?: number; } -const INITIAL_STATE: GithubStarState = { status: "unarmed", dismissCount: 0 }; +const INITIAL_STATE: GithubStarState = { status: "unarmed" }; /** * Tracks whether to nudge the user to star Rill on GitHub. * The nudge is armed by a dashboard render. - * Dismissal escalates rather than muting for a fixed period. After three soft dismissals, the nudge is retired. + * A soft dismissal mutes it for one day; only starring or opting out retires it. */ export class GithubStarNudge { private currentState = $state(INITIAL_STATE); @@ -39,8 +32,8 @@ export class GithubStarNudge { /** * Whether the nudge should be shown right now. - * Recomputed whenever the state changes; mutes last 30+ days, so re-reading on - * each app load is ample granularity for expiry. + * Recomputed whenever the state changes; re-reading on each app load is ample + * granularity for the one-day mute to expire. */ public get visible() { return isVisible(this.currentState); @@ -63,23 +56,15 @@ export class GithubStarNudge { } /** - * Any other exit: X, click-outside, timeout, navigating away. These are - * indistinguishable from the client, and not counting silent ignores would let - * engaged users be re-asked every 30 days with no backoff. + * A non-terminal exit, such as Escape, X, or click-outside, mutes the nudge + * until the following day. */ public recordSoftDismiss() { if (!isVisible(this.currentState)) return; - const dismissCount = this.currentState.dismissCount + 1; - const muteDays = MUTE_SCHEDULE_DAYS[dismissCount - 1]; - if (muteDays === undefined) { - this.commit({ ...this.currentState, status: "done", dismissCount }); - return; - } this.commit({ ...this.currentState, - dismissCount, - mutedUntil: Date.now() + muteDays * DAY_MS, + mutedUntil: Date.now() + DAY_MS, }); } @@ -105,7 +90,7 @@ function readState(): GithubStarState { return { ...INITIAL_STATE, ...parsed }; } catch { // Corrupt or unreadable state degrades to "unarmed" rather than throwing. - // The worst case is one extra ask, which the backoff then bounds. + // The worst case is one extra ask. return INITIAL_STATE; } } diff --git a/web-common/src/lib/i18n/messages/en.json b/web-common/src/lib/i18n/messages/en.json index bc5647acec0c..73143358dd81 100644 --- a/web-common/src/lib/i18n/messages/en.json +++ b/web-common/src/lib/i18n/messages/en.json @@ -1293,7 +1293,7 @@ "github_star_cta": "Star on GitHub", "github_star_dismiss": "Don't show again", "github_star_footer_label": "Star us on GitHub", - "github_star_message": "Rill is open source. A star on GitHub helps other data teams find us.", + "github_star_message": "A GitHub star helps more data teams discover Rill.", "github_star_title": "Enjoying Rill?", "github_subpath": "subpath", "github_subpath_label": "Subpath", diff --git a/web-common/src/lib/i18n/messages/es.json b/web-common/src/lib/i18n/messages/es.json index d812e544ea77..d5f0fc02426d 100644 --- a/web-common/src/lib/i18n/messages/es.json +++ b/web-common/src/lib/i18n/messages/es.json @@ -1302,7 +1302,7 @@ "github_star_cta": "Dar una estrella en GitHub", "github_star_dismiss": "No volver a mostrar", "github_star_footer_label": "Denos una estrella en GitHub", - "github_star_message": "Rill es de código abierto. Una estrella en GitHub ayuda a otros equipos de datos a encontrarnos.", + "github_star_message": "Una estrella en GitHub ayuda a que más equipos de datos descubran Rill.", "github_star_title": "¿Le está gustando Rill?", "github_subpath": "subruta", "github_subpath_label": "Subruta", From acb6c23311aa303a00eae383b5230540e9c24f0e Mon Sep 17 00:00:00 2001 From: Anshul Khandelwal <12948312+k-anshul@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:33:53 +0530 Subject: [PATCH 4/5] review --- .../github-star/GithubStarButton.spec.ts | 21 +++++-- .../github-star/GithubStarButton.svelte | 2 +- .../github-star/github-star.svelte.spec.ts | 26 +++++--- .../github-star/github-star.svelte.ts | 61 ++++++------------- .../svelte-local-storage.svelte.ts | 18 ++++++ 5 files changed, 70 insertions(+), 58 deletions(-) diff --git a/web-common/src/features/github-star/GithubStarButton.spec.ts b/web-common/src/features/github-star/GithubStarButton.spec.ts index 463707710a85..920de00b4b6a 100644 --- a/web-common/src/features/github-star/GithubStarButton.spec.ts +++ b/web-common/src/features/github-star/GithubStarButton.spec.ts @@ -2,16 +2,28 @@ import { render, screen } from "@testing-library/svelte"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { featureFlags } from "@rilldata/web-common/features/feature-flags"; +import { InMemoryRuneStore } from "@rilldata/web-common/lib/store-utils/types.svelte.ts"; import GithubStarButton from "./GithubStarButton.svelte"; -import { GITHUB_STAR_URL, GithubStarNudge } from "./github-star.svelte"; +import { + GITHUB_STAR_URL, + GithubStarNudge, + type GithubStarState, +} from "./github-star.svelte"; /** Lets the auto-open timer and the resulting Svelte update settle. */ function settle() { return vi.advanceTimersByTimeAsync(2000); } +/** A nudge of its own, so these tests neither touch localStorage nor the app-wide singleton. */ +function createNudge() { + return new GithubStarNudge( + new InMemoryRuneStore({ status: "unarmed" }), + ); +} + function renderArmed() { - const nudge = new GithubStarNudge(); + const nudge = createNudge(); nudge.armPayoff(); render(GithubStarButton, { props: { nudge } }); return nudge; @@ -29,7 +41,6 @@ function renderOutsideElement() { describe("GithubStarButton", () => { beforeEach(() => { - localStorage.clear(); featureFlags.adminServer.resetToDefault(); vi.useFakeTimers(); }); @@ -53,7 +64,7 @@ describe("GithubStarButton", () => { }); it("always renders the footer link, pointing straight at the repo", () => { - render(GithubStarButton, { props: { nudge: new GithubStarNudge() } }); + render(GithubStarButton, { props: { nudge: createNudge() } }); const link = screen.getByText("Star us on GitHub").closest("a"); expect(link).toHaveAttribute("href", GITHUB_STAR_URL); @@ -61,7 +72,7 @@ describe("GithubStarButton", () => { }); it("does not open unprompted when no payoff has happened", async () => { - render(GithubStarButton, { props: { nudge: new GithubStarNudge() } }); + render(GithubStarButton, { props: { nudge: createNudge() } }); await settle(); expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); diff --git a/web-common/src/features/github-star/GithubStarButton.svelte b/web-common/src/features/github-star/GithubStarButton.svelte index 317693f9339a..a077ae9b7d1f 100644 --- a/web-common/src/features/github-star/GithubStarButton.svelte +++ b/web-common/src/features/github-star/GithubStarButton.svelte @@ -13,7 +13,7 @@ type GithubStarNudge, } from "./github-star.svelte"; - /** Injectable so tests get an isolated instance instead of the module singleton. */ + /** Injectable so tests can supply a nudge backed by their own store. */ let { nudge = githubStarNudge }: { nudge?: GithubStarNudge } = $props(); /** Grace period so the nudge does not appear mid route transition. */ diff --git a/web-common/src/features/github-star/github-star.svelte.spec.ts b/web-common/src/features/github-star/github-star.svelte.spec.ts index ee211facfff9..ea848c90b506 100644 --- a/web-common/src/features/github-star/github-star.svelte.spec.ts +++ b/web-common/src/features/github-star/github-star.svelte.spec.ts @@ -1,3 +1,4 @@ +import { SvelteLocalStorage } from "@rilldata/web-common/lib/store-utils/svelte-local-storage.svelte.ts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GithubStarNudge } from "./github-star.svelte"; @@ -9,9 +10,16 @@ function atDay(days: number) { vi.setSystemTime(START + days * DAY_MS); } +/** A fresh nudge that re-reads localStorage, standing in for an app reload. */ +function reload() { + SvelteLocalStorage.clearInstanceCache(); + return new GithubStarNudge(); +} + describe("GithubStarNudge", () => { beforeEach(() => { localStorage.clear(); + SvelteLocalStorage.clearInstanceCache(); vi.useFakeTimers(); vi.setSystemTime(START); }); @@ -30,7 +38,7 @@ describe("GithubStarNudge", () => { it("persists the armed state across reloads", () => { new GithubStarNudge().armPayoff(); - expect(new GithubStarNudge().visible).toBe(true); + expect(reload().visible).toBe(true); }); it("treats a star click as terminal", () => { @@ -43,7 +51,7 @@ describe("GithubStarNudge", () => { nudge.armPayoff(); expect(nudge.visible).toBe(false); atDay(365); - expect(new GithubStarNudge().visible).toBe(false); + expect(reload().visible).toBe(false); }); it("treats an explicit opt-out as terminal", () => { @@ -52,7 +60,7 @@ describe("GithubStarNudge", () => { nudge.recordOptOut(); atDay(365); - expect(new GithubStarNudge().visible).toBe(false); + expect(reload().visible).toBe(false); }); it("mutes a soft dismissal until the following day", () => { @@ -63,9 +71,9 @@ describe("GithubStarNudge", () => { expect(nudge.state.status).toBe("armed"); expect(nudge.visible).toBe(false); atDay(0.99); - expect(new GithubStarNudge().visible).toBe(false); + expect(reload().visible).toBe(false); atDay(1); - expect(new GithubStarNudge().visible).toBe(true); + expect(reload().visible).toBe(true); }); it("continues asking daily until the user stars or opts out", () => { @@ -74,7 +82,7 @@ describe("GithubStarNudge", () => { let asks = 0; for (let day = 0; day < 400; day++) { atDay(day); - const nudge = new GithubStarNudge(); + const nudge = reload(); if (nudge.visible) { asks++; nudge.recordSoftDismiss(); @@ -82,7 +90,7 @@ describe("GithubStarNudge", () => { } expect(asks).toBe(400); - expect(new GithubStarNudge().state.status).toBe("armed"); + expect(reload().state.status).toBe("armed"); }); it("ignores a soft dismissal while muted", () => { @@ -114,10 +122,10 @@ describe("GithubStarNudge", () => { it("treats corrupt stored state as unarmed", () => { localStorage.setItem("rill:github-star", "{not json"); - expect(new GithubStarNudge().visible).toBe(false); + expect(reload().visible).toBe(false); localStorage.setItem("rill:github-star", JSON.stringify({ nonsense: 1 })); - expect(new GithubStarNudge().visible).toBe(false); + expect(reload().visible).toBe(false); }); it("degrades to in-memory when localStorage throws", () => { diff --git a/web-common/src/features/github-star/github-star.svelte.ts b/web-common/src/features/github-star/github-star.svelte.ts index 0d60d74fb622..ca8be43f220e 100644 --- a/web-common/src/features/github-star/github-star.svelte.ts +++ b/web-common/src/features/github-star/github-star.svelte.ts @@ -1,3 +1,6 @@ +import { SvelteLocalStorage } from "@rilldata/web-common/lib/store-utils/svelte-local-storage.svelte.ts"; +import type { RuneStore } from "@rilldata/web-common/lib/store-utils/types.svelte.ts"; + export const GITHUB_STAR_URL = "https://github.com/rilldata/rill"; const STORAGE_KEY = "rill:github-star"; @@ -20,14 +23,15 @@ const INITIAL_STATE: GithubStarState = { status: "unarmed" }; * A soft dismissal mutes it for one day; only starring or opting out retires it. */ export class GithubStarNudge { - private currentState = $state(INITIAL_STATE); - - public constructor() { - this.currentState = readState(); - } + public constructor( + private readonly store: RuneStore = SvelteLocalStorage.createJsonStore( + STORAGE_KEY, + INITIAL_STATE, + ), + ) {} public get state() { - return this.currentState; + return this.store.value; } /** @@ -36,23 +40,23 @@ export class GithubStarNudge { * granularity for the one-day mute to expire. */ public get visible() { - return isVisible(this.currentState); + return isVisible(this.store.value); } /** Called when the user reaches a payoff moment: a dashboard rendered. */ public armPayoff() { - if (this.currentState.status !== "unarmed") return; - this.commit({ ...this.currentState, status: "armed" }); + if (this.state.status === "armed" || this.state.status === "done") return; + this.store.setter({ ...this.state, status: "armed" }); } /** The user starred. Terminal. */ public recordStar() { - this.commit({ ...this.currentState, status: "done" }); + this.store.setter({ ...this.state, status: "done" }); } /** The user clicked "Don't show again". Terminal. */ public recordOptOut() { - this.commit({ ...this.currentState, status: "done" }); + this.store.setter({ ...this.state, status: "done" }); } /** @@ -60,18 +64,13 @@ export class GithubStarNudge { * until the following day. */ public recordSoftDismiss() { - if (!isVisible(this.currentState)) return; + if (!this.visible) return; - this.commit({ - ...this.currentState, + this.store.setter({ + ...this.state, mutedUntil: Date.now() + DAY_MS, }); } - - private commit(next: GithubStarState) { - this.currentState = next; - writeState(next); - } } export function isVisible(state: GithubStarState, now = Date.now()) { @@ -79,28 +78,4 @@ export function isVisible(state: GithubStarState, now = Date.now()) { return !state.mutedUntil || state.mutedUntil <= now; } -function readState(): GithubStarState { - try { - // Accessing localStorage can throw rather than return null: a sandboxed or - // storage-partitioned iframe raises a SecurityError, and it is absent on the server. - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return INITIAL_STATE; - const parsed = JSON.parse(raw) as GithubStarState; - if (!parsed || typeof parsed.status !== "string") return INITIAL_STATE; - return { ...INITIAL_STATE, ...parsed }; - } catch { - // Corrupt or unreadable state degrades to "unarmed" rather than throwing. - // The worst case is one extra ask. - return INITIAL_STATE; - } -} - -function writeState(state: GithubStarState) { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); - } catch { - // no-op: nudge degrades to in-memory only - } -} - export const githubStarNudge = new GithubStarNudge(); diff --git a/web-common/src/lib/store-utils/svelte-local-storage.svelte.ts b/web-common/src/lib/store-utils/svelte-local-storage.svelte.ts index b9628b4cf5c3..68b5f0e5762b 100644 --- a/web-common/src/lib/store-utils/svelte-local-storage.svelte.ts +++ b/web-common/src/lib/store-utils/svelte-local-storage.svelte.ts @@ -49,6 +49,24 @@ export class SvelteLocalStorage return store; } + /** + * Drops the cache so that the next `getInstance` re-reads localStorage. + * Instances already handed out keep the store they were given. + */ + public static clearInstanceCache() { + this.stores.clear(); + } + + /** For values that round-trip through JSON, such as objects and records. */ + public static createJsonStore(key: string, defaultVal: Val) { + return SvelteLocalStorage.getInstance( + key, + (value: Val) => JSON.stringify(value), + (value) => (value ? (JSON.parse(value) as Val) : defaultVal), + defaultVal, + ); + } + public static createStringArrayStore(key: string) { return new ArrayRuneStore( SvelteLocalStorage.getInstance( From 36f94727d4b95c574564add37554a586ef035e85 Mon Sep 17 00:00:00 2001 From: Anshul Khandelwal <12948312+k-anshul@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:19:22 +0530 Subject: [PATCH 5/5] nits --- .../github-star/github-star.svelte.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/web-common/src/features/github-star/github-star.svelte.ts b/web-common/src/features/github-star/github-star.svelte.ts index ca8be43f220e..68fae386fd7e 100644 --- a/web-common/src/features/github-star/github-star.svelte.ts +++ b/web-common/src/features/github-star/github-star.svelte.ts @@ -40,23 +40,26 @@ export class GithubStarNudge { * granularity for the one-day mute to expire. */ public get visible() { - return isVisible(this.store.value); + const { status, mutedUntil } = this.store.value; + if (status !== "armed") return false; + return !mutedUntil || mutedUntil <= Date.now(); } /** Called when the user reaches a payoff moment: a dashboard rendered. */ public armPayoff() { - if (this.state.status === "armed" || this.state.status === "done") return; - this.store.setter({ ...this.state, status: "armed" }); + const { status } = this.store.value; + if (status === "armed" || status === "done") return; + this.store.setter({ ...this.store.value, status: "armed" }); } /** The user starred. Terminal. */ public recordStar() { - this.store.setter({ ...this.state, status: "done" }); + this.store.setter({ ...this.store.value, status: "done" }); } /** The user clicked "Don't show again". Terminal. */ public recordOptOut() { - this.store.setter({ ...this.state, status: "done" }); + this.store.setter({ ...this.store.value, status: "done" }); } /** @@ -67,15 +70,10 @@ export class GithubStarNudge { if (!this.visible) return; this.store.setter({ - ...this.state, + ...this.store.value, mutedUntil: Date.now() + DAY_MS, }); } } -export function isVisible(state: GithubStarState, now = Date.now()) { - if (state.status !== "armed") return false; - return !state.mutedUntil || state.mutedUntil <= now; -} - export const githubStarNudge = new GithubStarNudge();
+ {m.github_star_message()} +
- {m.github_star_message()} -