Compare commits

..

1 Commits

Author SHA1 Message Date
Aidan Timson dccad52c07 Add typed query param helpers, add to history and activity 2026-06-05 11:55:17 +01:00
6 changed files with 408 additions and 142 deletions
@@ -0,0 +1,73 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import {
createQueryString,
decodeQueryParams,
queryParamsFromServiceTarget,
serviceTargetFromQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
export type HistoryLogbookTargetParamKey =
| "entity_id"
| "label_id"
| "floor_id"
| "area_id"
| "device_id";
export type HistoryLogbookDateParamKey = "start_date" | "end_date";
export type HistoryLogbookBooleanParamKey = "back";
export type HistoryLogbookQueryParams = QueryParamValues<
HistoryLogbookTargetParamKey,
HistoryLogbookDateParamKey,
HistoryLogbookBooleanParamKey
>;
export const historyLogbookTargetParamKeys: HistoryLogbookTargetParamKey[] = [
"entity_id",
"label_id",
"floor_id",
"area_id",
"device_id",
];
export const historyLogbookQueryParamConfig = {
list: historyLogbookTargetParamKeys,
date: ["start_date", "end_date"],
boolean: [{ key: "back", trueValue: "1" }],
} satisfies QueryParamConfig<
HistoryLogbookTargetParamKey,
HistoryLogbookDateParamKey,
HistoryLogbookBooleanParamKey
>;
export const decodeHistoryLogbookQueryParams = (
searchParams: SearchParamsSource
): HistoryLogbookQueryParams =>
decodeQueryParams(searchParams, historyLogbookQueryParamConfig);
export const historyLogbookTargetFromQueryParams = (
params: HistoryLogbookQueryParams
): HassServiceTarget | undefined =>
serviceTargetFromQueryParams(params, historyLogbookTargetParamKeys);
export const createHistoryLogbookUrl = (
path: string,
target: HassServiceTarget,
startDate: Date,
endDate: Date
): string => {
const queryString = createQueryString(
{
...queryParamsFromServiceTarget(target, historyLogbookTargetParamKeys),
start_date: startDate,
end_date: endDate,
},
historyLogbookQueryParamConfig
);
return queryString ? `${path}?${queryString}` : path;
};
+140
View File
@@ -0,0 +1,140 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../array/ensure-array";
export type SearchParamsSource =
| URLSearchParams
| Record<string, string>
| string;
export interface QueryParamConfig<
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
> {
list?: readonly ListKey[];
date?: readonly DateKey[];
boolean?: readonly {
key: BooleanKey;
trueValue: string;
}[];
}
export type QueryParamValues<
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
> = Partial<
Record<ListKey, string[]> &
Record<DateKey, Date> &
Record<BooleanKey, boolean>
>;
export type ServiceTargetQueryParams<
Key extends keyof HassServiceTarget & string,
> = Partial<Record<Key, string[]>>;
const getSearchParam = (
searchParams: SearchParamsSource,
key: string
): string | null => {
if (typeof searchParams === "string") {
return new URLSearchParams(searchParams).get(key);
}
if (searchParams instanceof URLSearchParams) {
return searchParams.get(key);
}
return searchParams[key] ?? null;
};
export const decodeQueryParams = <
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
>(
searchParams: SearchParamsSource,
config: QueryParamConfig<ListKey, DateKey, BooleanKey>
): QueryParamValues<ListKey, DateKey, BooleanKey> => {
const params: QueryParamValues<ListKey, DateKey, BooleanKey> = {};
for (const key of config.list ?? []) {
const value = getSearchParam(searchParams, key);
if (value) {
params[key] = value.split(",") as (typeof params)[typeof key];
}
}
for (const key of config.date ?? []) {
const value = getSearchParam(searchParams, key);
if (value) {
params[key] = new Date(value) as (typeof params)[typeof key];
}
}
for (const { key, trueValue } of config.boolean ?? []) {
if (getSearchParam(searchParams, key) === trueValue) {
params[key] = true as (typeof params)[typeof key];
}
}
return params;
};
export const createQueryString = <
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
>(
values: QueryParamValues<ListKey, DateKey, BooleanKey>,
config: QueryParamConfig<ListKey, DateKey, BooleanKey>
): string => {
const searchParams = new URLSearchParams();
for (const key of config.list ?? []) {
const value = values[key] as string[] | undefined;
if (value?.length) {
searchParams.append(key, value.join(","));
}
}
for (const key of config.date ?? []) {
const value = values[key] as Date | undefined;
if (value) {
searchParams.append(key, value.toISOString());
}
}
for (const { key, trueValue } of config.boolean ?? []) {
if (values[key]) {
searchParams.append(key, trueValue);
}
}
return searchParams.toString();
};
export const serviceTargetFromQueryParams = <
Key extends keyof HassServiceTarget & string,
>(
params: ServiceTargetQueryParams<Key>,
keys: readonly Key[]
): HassServiceTarget | undefined => {
if (!keys.some((key) => params[key])) {
return undefined;
}
const target: HassServiceTarget = {};
for (const key of keys) {
const value = params[key];
if (value) {
target[key] = value;
}
}
return target;
};
export const queryParamsFromServiceTarget = <
Key extends keyof HassServiceTarget & string,
>(
target: HassServiceTarget,
keys: readonly Key[]
): ServiceTargetQueryParams<Key> => {
const params: ServiceTargetQueryParams<Key> = {};
for (const key of keys) {
const value = target[key];
if (value) {
params[key] = ensureArray(value);
}
}
return params;
};
+24 -68
View File
@@ -13,13 +13,16 @@ import type { PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { storage } from "../../common/decorators/storage";
import { computeDomain } from "../../common/entity/compute_domain";
import { goBack, navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
createSearchParam,
createHistoryLogbookUrl,
decodeHistoryLogbookQueryParams,
historyLogbookTargetFromQueryParams,
} from "../../common/url/history-logbook-query-params";
import {
extractSearchParamsObject,
removeSearchParam,
} from "../../common/url/search-params";
@@ -230,43 +233,18 @@ class HaPanelHistory extends LitElement {
return;
}
const searchParams = extractSearchParamsObject();
const entityIds = searchParams.entity_id;
const deviceIds = searchParams.device_id;
const areaIds = searchParams.area_id;
const floorIds = searchParams.floor_id;
const labelsIds = searchParams.label_id;
if (entityIds || deviceIds || areaIds || floorIds || labelsIds) {
this._targetPickerValue = {};
const queryParams = decodeHistoryLogbookQueryParams(
extractSearchParamsObject()
);
const targetPickerValue = historyLogbookTargetFromQueryParams(queryParams);
if (targetPickerValue) {
this._targetPickerValue = targetPickerValue;
}
if (entityIds) {
const splitIds = entityIds.split(",");
this._targetPickerValue!.entity_id = splitIds;
if (queryParams.start_date) {
this._startDate = queryParams.start_date;
}
if (deviceIds) {
const splitIds = deviceIds.split(",");
this._targetPickerValue!.device_id = splitIds;
}
if (areaIds) {
const splitIds = areaIds.split(",");
this._targetPickerValue!.area_id = splitIds;
}
if (floorIds) {
const splitIds = floorIds.split(",");
this._targetPickerValue!.floor_id = splitIds;
}
if (labelsIds) {
const splitIds = labelsIds.split(",");
this._targetPickerValue!.label_id = splitIds;
}
const startDate = searchParams.start_date;
if (startDate) {
this._startDate = new Date(startDate);
}
const endDate = searchParams.end_date;
if (endDate) {
this._endDate = new Date(endDate);
if (queryParams.end_date) {
this._endDate = queryParams.end_date;
}
}
@@ -469,37 +447,15 @@ class HaPanelHistory extends LitElement {
}
private _updatePath() {
const params: Record<string, string> = {};
if (this._targetPickerValue.entity_id) {
params.entity_id = ensureArray(this._targetPickerValue.entity_id).join(
","
);
}
if (this._targetPickerValue.label_id) {
params.label_id = ensureArray(this._targetPickerValue.label_id).join(",");
}
if (this._targetPickerValue.floor_id) {
params.floor_id = ensureArray(this._targetPickerValue.floor_id).join(",");
}
if (this._targetPickerValue.area_id) {
params.area_id = ensureArray(this._targetPickerValue.area_id).join(",");
}
if (this._targetPickerValue.device_id) {
params.device_id = ensureArray(this._targetPickerValue.device_id).join(
","
);
}
if (this._startDate) {
params.start_date = this._startDate.toISOString();
}
if (this._endDate) {
params.end_date = this._endDate.toISOString();
}
navigate(`/history?${createSearchParam(params)}`, { replace: true });
navigate(
createHistoryLogbookUrl(
"/history",
this._targetPickerValue,
this._startDate,
this._endDate
),
{ replace: true }
);
}
private async _handleMenuAction(ev: HaDropdownSelectEvent) {
+25 -71
View File
@@ -4,12 +4,15 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { storage } from "../../common/decorators/storage";
import { goBack, navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
createSearchParam,
createHistoryLogbookUrl,
decodeHistoryLogbookQueryParams,
historyLogbookTargetFromQueryParams,
} from "../../common/url/history-logbook-query-params";
import {
extractSearchParamsObject,
removeSearchParam,
} from "../../common/url/search-params";
@@ -185,44 +188,17 @@ export class HaPanelLogbook extends LitElement {
);
private _applyURLParams() {
const searchParams = extractSearchParamsObject();
const entityIds = searchParams.entity_id;
const deviceIds = searchParams.device_id;
const areaIds = searchParams.area_id;
const floorIds = searchParams.floor_id;
const labelsIds = searchParams.label_id;
if (entityIds || deviceIds || areaIds || floorIds || labelsIds) {
this._targetPickerValue = {};
}
if (entityIds) {
const splitIds = entityIds.split(",");
this._targetPickerValue!.entity_id = splitIds;
}
if (deviceIds) {
const splitIds = deviceIds.split(",");
this._targetPickerValue!.device_id = splitIds;
}
if (areaIds) {
const splitIds = areaIds.split(",");
this._targetPickerValue!.area_id = splitIds;
}
if (floorIds) {
const splitIds = floorIds.split(",");
this._targetPickerValue!.floor_id = splitIds;
}
if (labelsIds) {
const splitIds = labelsIds.split(",");
this._targetPickerValue!.label_id = splitIds;
const queryParams = decodeHistoryLogbookQueryParams(
extractSearchParamsObject()
);
const targetPickerValue = historyLogbookTargetFromQueryParams(queryParams);
if (targetPickerValue) {
this._targetPickerValue = targetPickerValue;
}
const startDateStr = searchParams.start_date;
const endDateStr = searchParams.end_date;
if (startDateStr || endDateStr) {
const startDate = startDateStr
? new Date(startDateStr)
: this._time.range[0];
const endDate = endDateStr ? new Date(endDateStr) : this._time.range[1];
if (queryParams.start_date || queryParams.end_date) {
const startDate = queryParams.start_date ?? this._time.range[0];
const endDate = queryParams.end_date ?? this._time.range[1];
// Only set if date has changed.
if (
@@ -231,8 +207,8 @@ export class HaPanelLogbook extends LitElement {
) {
this._time = {
range: [
startDateStr ? new Date(startDateStr) : this._time.range[0],
endDateStr ? new Date(endDateStr) : this._time.range[1],
queryParams.start_date ?? this._time.range[0],
queryParams.end_date ?? this._time.range[1],
],
};
}
@@ -254,37 +230,15 @@ export class HaPanelLogbook extends LitElement {
}
private _updatePath() {
const params: Record<string, string> = {};
if (this._targetPickerValue.entity_id) {
params.entity_id = ensureArray(this._targetPickerValue.entity_id).join(
","
);
}
if (this._targetPickerValue.label_id) {
params.label_id = ensureArray(this._targetPickerValue.label_id).join(",");
}
if (this._targetPickerValue.floor_id) {
params.floor_id = ensureArray(this._targetPickerValue.floor_id).join(",");
}
if (this._targetPickerValue.area_id) {
params.area_id = ensureArray(this._targetPickerValue.area_id).join(",");
}
if (this._targetPickerValue.device_id) {
params.device_id = ensureArray(this._targetPickerValue.device_id).join(
","
);
}
if (this._time.range[0]) {
params.start_date = this._time.range[0].toISOString();
}
if (this._time.range[1]) {
params.end_date = this._time.range[1].toISOString();
}
navigate(`/logbook?${createSearchParam(params)}`, { replace: true });
navigate(
createHistoryLogbookUrl(
"/logbook",
this._targetPickerValue,
this._time.range[0],
this._time.range[1]
),
{ replace: true }
);
}
private _refreshLogbook() {
-3
View File
@@ -296,9 +296,6 @@ export const getMyRedirects = (): Redirects => ({
component: "history",
redirect: "/history",
},
maintenance: {
redirect: "/maintenance",
},
overview: {
redirect: "/home/overview",
},
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import {
createHistoryLogbookUrl,
decodeHistoryLogbookQueryParams,
historyLogbookQueryParamConfig,
historyLogbookTargetParamKeys,
historyLogbookTargetFromQueryParams,
} from "../../../src/common/url/history-logbook-query-params";
import {
createQueryString,
decodeQueryParams,
queryParamsFromServiceTarget,
serviceTargetFromQueryParams,
} from "../../../src/common/url/query-params";
const panelQueryParams = [
{
type: "history",
path: "/history",
},
{
type: "logbook",
path: "/logbook",
},
] as const;
describe.each(panelQueryParams)("$type query params", (panel) => {
it("decodes target and date params", () => {
const params = decodeQueryParams(
"?entity_id=light.kitchen,switch.fan&device_id=device-1&area_id=kitchen&floor_id=downstairs&label_id=important&start_date=2026-06-05T10:00:00.000Z&end_date=2026-06-05T11:00:00.000Z&back=1",
historyLogbookQueryParamConfig
);
expect(params).toEqual({
entity_id: ["light.kitchen", "switch.fan"],
label_id: ["important"],
floor_id: ["downstairs"],
area_id: ["kitchen"],
device_id: ["device-1"],
start_date: new Date("2026-06-05T10:00:00.000Z"),
end_date: new Date("2026-06-05T11:00:00.000Z"),
back: true,
});
});
it("creates target picker values only when target params are present", () => {
expect(
serviceTargetFromQueryParams(
decodeQueryParams(
"?start_date=2026-06-05T10:00:00.000Z",
historyLogbookQueryParamConfig
),
historyLogbookTargetParamKeys
)
).toBeUndefined();
expect(
serviceTargetFromQueryParams(
decodeQueryParams(
"?entity_id=light.kitchen&area_id=kitchen",
historyLogbookQueryParamConfig
),
historyLogbookTargetParamKeys
)
).toEqual({
entity_id: ["light.kitchen"],
area_id: ["kitchen"],
});
});
it("ignores empty target values", () => {
expect(
serviceTargetFromQueryParams(
decodeQueryParams(
"?entity_id=&device_id=",
historyLogbookQueryParamConfig
),
historyLogbookTargetParamKeys
)
).toBeUndefined();
});
it("encodes target picker values", () => {
expect(
queryParamsFromServiceTarget(
{
entity_id: ["light.kitchen", "switch.fan"],
area_id: "kitchen",
},
historyLogbookTargetParamKeys
)
).toEqual({
entity_id: ["light.kitchen", "switch.fan"],
area_id: ["kitchen"],
});
});
it("creates deterministic query strings", () => {
expect(
createQueryString(
{
device_id: ["device-1"],
entity_id: ["light.kitchen"],
start_date: new Date("2026-06-05T10:00:00.000Z"),
end_date: new Date("2026-06-05T11:00:00.000Z"),
},
historyLogbookQueryParamConfig
)
).toBe(
"entity_id=light.kitchen&device_id=device-1&start_date=2026-06-05T10%3A00%3A00.000Z&end_date=2026-06-05T11%3A00%3A00.000Z"
);
});
it("creates typed URLs", () => {
expect(
createHistoryLogbookUrl(
panel.path,
{ entity_id: ["light.kitchen"] },
new Date("2026-06-05T10:00:00.000Z"),
new Date("2026-06-05T11:00:00.000Z")
)
).toBe(
`${panel.path}?entity_id=light.kitchen&start_date=2026-06-05T10%3A00%3A00.000Z&end_date=2026-06-05T11%3A00%3A00.000Z`
);
});
});
describe("history logbook query params", () => {
it("decodes query params", () => {
expect(
decodeHistoryLogbookQueryParams("?entity_id=light.kitchen&back=1")
).toEqual({
entity_id: ["light.kitchen"],
back: true,
});
});
it("creates target picker values only when target params are present", () => {
expect(
historyLogbookTargetFromQueryParams(
decodeHistoryLogbookQueryParams("?start_date=2026-06-05T10:00:00.000Z")
)
).toBeUndefined();
});
});