mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-21 18:47:04 +00:00
Compare commits
1 Commits
dev
...
onboarding-survey
| Author | SHA1 | Date | |
|---|---|---|---|
| 21efe1aa53 |
@@ -1,6 +1,11 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import type { ShortcutItem } from "./home_shortcuts";
|
||||
|
||||
export interface SurveyInteraction {
|
||||
date: string;
|
||||
action: "opened" | "dismissed";
|
||||
}
|
||||
|
||||
export interface CoreFrontendUserData {
|
||||
showEntityIdPicker?: boolean;
|
||||
default_panel?: string;
|
||||
@@ -16,6 +21,9 @@ export interface CoreFrontendSystemData {
|
||||
default_panel?: string;
|
||||
onboarded_version?: string;
|
||||
onboarded_date?: string;
|
||||
surveys?: {
|
||||
onboarding?: SurveyInteraction;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HomeFrontendSystemData {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
removeLaunchScreen,
|
||||
renderLaunchScreenInfoBox,
|
||||
} from "../util/launch-screen";
|
||||
import { checkOnboardingSurveyToast } from "../util/onboarding-survey";
|
||||
import {
|
||||
registerServiceWorker,
|
||||
supportsServiceWorker,
|
||||
@@ -59,6 +60,8 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
|
||||
private _httpPendingDialogOpen = false;
|
||||
|
||||
private _onboardingSurveyChecked = false;
|
||||
|
||||
private _panelUrl: string;
|
||||
|
||||
@storage({ key: "ha-version", state: false, subscribe: false })
|
||||
@@ -108,6 +111,17 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
) {
|
||||
this.checkHttpPendingConfig();
|
||||
}
|
||||
if (
|
||||
changedProps.has("hass") &&
|
||||
!this._onboardingSurveyChecked &&
|
||||
this.hass?.user &&
|
||||
this.hass.systemData
|
||||
) {
|
||||
this._onboardingSurveyChecked = true;
|
||||
if (!__DEMO__) {
|
||||
checkOnboardingSurveyToast(this, this.hass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected update(changedProps: PropertyValues<this>) {
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ShowToastParams {
|
||||
message:
|
||||
string | { translationKey: LocalizeKeys; args?: Record<string, string> };
|
||||
action?: ToastActionParams;
|
||||
dismiss?: () => void;
|
||||
duration?: number;
|
||||
dismissable?: boolean;
|
||||
bottomOffset?: number;
|
||||
@@ -71,7 +72,10 @@ class NotificationManager extends LitElement {
|
||||
this._toast?.show();
|
||||
}
|
||||
|
||||
private _toastClosed(_ev: HASSDomEvent<ToastClosedEventDetail>) {
|
||||
private _toastClosed(ev: HASSDomEvent<ToastClosedEventDetail>) {
|
||||
if (ev.detail.reason === "dismiss") {
|
||||
this._parameters?.dismiss?.();
|
||||
}
|
||||
this._parameters = undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -2511,7 +2511,11 @@
|
||||
"new_version_available": "A new version of the frontend is available.",
|
||||
"reload": "Reload",
|
||||
"theme_save_failed": "Unable to save theme settings to your user profile.",
|
||||
"theme_preferences_unavailable": "Unable to load user profile theme settings."
|
||||
"theme_preferences_unavailable": "Unable to load user profile theme settings.",
|
||||
"onboarding_survey": {
|
||||
"message": "Hello there! 👋 You've been using Home Assistant for a little while now. We'd love to hear what you think. Just one quick minute!",
|
||||
"action": "Take survey"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"external_app_configuration": "App settings",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import type {
|
||||
CoreFrontendSystemData,
|
||||
SurveyInteraction,
|
||||
} from "../data/frontend";
|
||||
import { saveFrontendSystemData } from "../data/frontend";
|
||||
import type { CurrentUser, HomeAssistant } from "../types";
|
||||
import { showToast } from "./toast";
|
||||
|
||||
const SURVEY_MIN_AGE = 5 * 24 * 60 * 60 * 1000; // 5 days
|
||||
const SURVEY_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 1 month
|
||||
|
||||
// Always the production site: the survey is a single page that is not
|
||||
// version-specific (the version is passed as a query parameter instead), so
|
||||
// beta/dev installs should not be routed to rc./next. like documentationUrl
|
||||
// would.
|
||||
const SURVEY_URL = "https://www.home-assistant.io/surveys/onboarding";
|
||||
|
||||
export const shouldShowOnboardingSurvey = (
|
||||
user: CurrentUser | undefined,
|
||||
systemData: CoreFrontendSystemData | undefined,
|
||||
now: number = Date.now()
|
||||
): boolean => {
|
||||
if (!user?.is_owner || !systemData?.onboarded_date) {
|
||||
return false;
|
||||
}
|
||||
if (systemData.surveys?.onboarding) {
|
||||
return false;
|
||||
}
|
||||
const age = now - new Date(systemData.onboarded_date).getTime();
|
||||
// NaN (invalid date) and future dates (clock skew) both fail these checks
|
||||
return age >= SURVEY_MIN_AGE && age <= SURVEY_MAX_AGE;
|
||||
};
|
||||
|
||||
export const recordOnboardingSurvey = (
|
||||
conn: Connection,
|
||||
systemData: CoreFrontendSystemData,
|
||||
action: SurveyInteraction["action"]
|
||||
): Promise<void> =>
|
||||
// saveFrontendSystemData overwrites the whole "core" object, so spread the
|
||||
// existing data to preserve the other fields.
|
||||
saveFrontendSystemData(conn, "core", {
|
||||
...systemData,
|
||||
surveys: {
|
||||
...systemData.surveys,
|
||||
onboarding: { date: new Date().toISOString(), action },
|
||||
},
|
||||
});
|
||||
|
||||
export const getOnboardingSurveyUrl = (
|
||||
systemData: CoreFrontendSystemData
|
||||
): string =>
|
||||
systemData.onboarded_version
|
||||
? `${SURVEY_URL}?version=${encodeURIComponent(systemData.onboarded_version)}`
|
||||
: SURVEY_URL;
|
||||
|
||||
export const checkOnboardingSurveyToast = (
|
||||
el: HTMLElement,
|
||||
hass: HomeAssistant
|
||||
) => {
|
||||
if (!shouldShowOnboardingSurvey(hass.user, hass.systemData)) {
|
||||
return;
|
||||
}
|
||||
const record = (action: SurveyInteraction["action"]) =>
|
||||
recordOnboardingSurvey(hass.connection, hass.systemData!, action);
|
||||
showToast(el, {
|
||||
id: "onboarding-survey",
|
||||
message: {
|
||||
translationKey: "ui.notification_toast.onboarding_survey.message",
|
||||
},
|
||||
duration: -1,
|
||||
dismissable: true,
|
||||
action: {
|
||||
text: {
|
||||
translationKey: "ui.notification_toast.onboarding_survey.action",
|
||||
},
|
||||
action: () => {
|
||||
window.open(getOnboardingSurveyUrl(hass.systemData!), "_blank");
|
||||
record("opened");
|
||||
},
|
||||
},
|
||||
dismiss: () => record("dismissed"),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import {
|
||||
afterEach,
|
||||
assert,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import type { CoreFrontendSystemData } from "../../src/data/frontend";
|
||||
import type { CurrentUser } from "../../src/types";
|
||||
import {
|
||||
getOnboardingSurveyUrl,
|
||||
recordOnboardingSurvey,
|
||||
shouldShowOnboardingSurvey,
|
||||
} from "../../src/util/onboarding-survey";
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const NOW = new Date("2026-07-20T12:00:00.000Z").getTime();
|
||||
|
||||
const daysAgo = (days: number) => new Date(NOW - days * DAY).toISOString();
|
||||
|
||||
const owner = { is_owner: true } as CurrentUser;
|
||||
const nonOwner = { is_owner: false } as CurrentUser;
|
||||
|
||||
describe("shouldShowOnboardingSurvey", () => {
|
||||
it("returns false without a user", () => {
|
||||
assert.isFalse(
|
||||
shouldShowOnboardingSurvey(undefined, { onboarded_date: daysAgo(6) }, NOW)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for a non-owner user", () => {
|
||||
assert.isFalse(
|
||||
shouldShowOnboardingSurvey(nonOwner, { onboarded_date: daysAgo(6) }, NOW)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false without system data", () => {
|
||||
assert.isFalse(shouldShowOnboardingSurvey(owner, undefined, NOW));
|
||||
assert.isFalse(shouldShowOnboardingSurvey(owner, {}, NOW));
|
||||
});
|
||||
|
||||
it("returns false when onboarded less than 5 days ago", () => {
|
||||
assert.isFalse(
|
||||
shouldShowOnboardingSurvey(owner, { onboarded_date: daysAgo(4) }, NOW)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when onboarded more than 30 days ago", () => {
|
||||
assert.isFalse(
|
||||
shouldShowOnboardingSurvey(owner, { onboarded_date: daysAgo(31) }, NOW)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for an invalid date", () => {
|
||||
assert.isFalse(
|
||||
shouldShowOnboardingSurvey(owner, { onboarded_date: "not-a-date" }, NOW)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for a date in the future", () => {
|
||||
assert.isFalse(
|
||||
shouldShowOnboardingSurvey(owner, { onboarded_date: daysAgo(-1) }, NOW)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when already interacted with", () => {
|
||||
assert.isFalse(
|
||||
shouldShowOnboardingSurvey(
|
||||
owner,
|
||||
{
|
||||
onboarded_date: daysAgo(6),
|
||||
surveys: { onboarding: { date: daysAgo(1), action: "dismissed" } },
|
||||
},
|
||||
NOW
|
||||
)
|
||||
);
|
||||
assert.isFalse(
|
||||
shouldShowOnboardingSurvey(
|
||||
owner,
|
||||
{
|
||||
onboarded_date: daysAgo(6),
|
||||
surveys: { onboarding: { date: daysAgo(1), action: "opened" } },
|
||||
},
|
||||
NOW
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true within the 5-30 day window", () => {
|
||||
assert.isTrue(
|
||||
shouldShowOnboardingSurvey(owner, { onboarded_date: daysAgo(5) }, NOW)
|
||||
);
|
||||
assert.isTrue(
|
||||
shouldShowOnboardingSurvey(owner, { onboarded_date: daysAgo(15) }, NOW)
|
||||
);
|
||||
assert.isTrue(
|
||||
shouldShowOnboardingSurvey(owner, { onboarded_date: daysAgo(30) }, NOW)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true when surveys exists without the onboarding flag", () => {
|
||||
assert.isTrue(
|
||||
shouldShowOnboardingSurvey(
|
||||
owner,
|
||||
{ onboarded_date: daysAgo(6), surveys: {} },
|
||||
NOW
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordOnboardingSurvey", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("stores the interaction and preserves existing fields", async () => {
|
||||
const sendMessagePromise = vi.fn().mockResolvedValue(undefined);
|
||||
const conn = { sendMessagePromise } as unknown as Connection;
|
||||
const systemData: CoreFrontendSystemData = {
|
||||
default_panel: "lovelace",
|
||||
onboarded_version: "2026.7.0",
|
||||
onboarded_date: daysAgo(6),
|
||||
};
|
||||
|
||||
await recordOnboardingSurvey(conn, systemData, "dismissed");
|
||||
|
||||
expect(sendMessagePromise).toHaveBeenCalledWith({
|
||||
type: "frontend/set_system_data",
|
||||
key: "core",
|
||||
value: {
|
||||
default_panel: "lovelace",
|
||||
onboarded_version: "2026.7.0",
|
||||
onboarded_date: daysAgo(6),
|
||||
surveys: {
|
||||
onboarding: {
|
||||
date: new Date(NOW).toISOString(),
|
||||
action: "dismissed",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("merges an existing surveys object", async () => {
|
||||
const sendMessagePromise = vi.fn().mockResolvedValue(undefined);
|
||||
const conn = { sendMessagePromise } as unknown as Connection;
|
||||
|
||||
await recordOnboardingSurvey(conn, { surveys: {} }, "opened");
|
||||
|
||||
expect(sendMessagePromise).toHaveBeenCalledWith({
|
||||
type: "frontend/set_system_data",
|
||||
key: "core",
|
||||
value: {
|
||||
surveys: {
|
||||
onboarding: { date: new Date(NOW).toISOString(), action: "opened" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOnboardingSurveyUrl", () => {
|
||||
it("includes the version param and ignores the onboard date", () => {
|
||||
assert.strictEqual(
|
||||
getOnboardingSurveyUrl({
|
||||
onboarded_version: "2026.7.0",
|
||||
onboarded_date: "2026-07-14T12:00:00.000Z",
|
||||
}),
|
||||
"https://www.home-assistant.io/surveys/onboarding?version=2026.7.0"
|
||||
);
|
||||
});
|
||||
|
||||
it("omits a missing version param", () => {
|
||||
assert.strictEqual(
|
||||
getOnboardingSurveyUrl({}),
|
||||
"https://www.home-assistant.io/surveys/onboarding"
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user