Compare commits

...

1 Commits

Author SHA1 Message Date
Maarten Lakerveld 21efe1aa53 Conditions/triggers/actions use the same logic for the automations picker sorting 2026-07-21 16:51:10 +02:00
6 changed files with 304 additions and 2 deletions
+8
View File
@@ -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 {
+14
View File
@@ -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>) {
+5 -1
View File
@@ -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;
}
+5 -1
View File
@@ -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",
+84
View File
@@ -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"),
});
};
+188
View File
@@ -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"
);
});
});