Skip to content

Commit fa3f832

Browse files
authored
Fix sorting logic of RegistryFilter
- Extract value comparison and priority logic into dedicated methods. This enhances readability and reduces cyclomatic complexity. - Fix sorting instability where mixed types (e.g., null vs string) caused inconsistent ties. This prevents JavaScript's unreliable `<` and `>` comparisons from returning a 0 for different types. - Implement natural numeric sorting for strings. (e.g., "Item 2" before "Item 10"). This ensures digits within strings are treated as numerical values instead of ASCII characters. - Optimize performance with early-exit identity checks (a === b). This allows the loop to immediately move to the next property without redundant processing. - Add documentation to clarify how the `order` property affects the sorting of areas and domains in views. This helps users understand how to prioritize their most-used items effectively.
1 parent 665f971 commit fa3f832

File tree

5 files changed

+130
-64
lines changed

5 files changed

+130
-64
lines changed

docs/options/area-options.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@ strategy:
6464
views: []
6565
```
6666
67+
## Sorting Areas
68+
69+
The `order` property gives you control over how your areas are arranged in a view.
70+
71+
To make the most of this, it helps to understand how the system prioritizes your list:
72+
73+
- Any area assigned an order value will automatically move to the front/top.<br>
74+
This allows you to "pin" your most-used rooms—like the Kitchen or Living Room—so they are always the first things you
75+
see.
76+
- If two areas share the same order value, the system will use their names to determine which one comes first.
77+
- Any areas without an order property will be placed after/below your prioritized list, sorted alphabetically by name.
78+
6779
## Undisclosed Area
6880

6981
The strategy has a special area, named `undisclosed`.

docs/options/domain-options.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ The number of cards per row can be configured with this option.
2525

2626
---
2727

28+
## Sorting Domains
29+
30+
The `order` property gives you control over how the domains are arranged in a view.
31+
32+
To make the most of this, it helps to understand how the system prioritizes your list:
33+
34+
- Any domain assigned an order value will automatically move to the front/top.<br>
35+
This allows you to "pin" your most-used domains—like lights or fans—so they are always the first things you
36+
see.
37+
- If two domains share the same order value, the system will use their names to determine which one comes first.
38+
- Any domain without an order property will be placed after/below your prioritized list, sorted alphabetically by name.
39+
2840
## Setting options for all domains
2941

3042
Use `_` as the identifier to set options for all domains.

src/Registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ class Registry {
181181
};
182182
});
183183

184-
// Remove hidden areas if configured as so and sort them by name.
184+
// Remove hidden areas if configured as so and sort them by order first, then by name.
185185
Registry._areas = new RegistryFilter(Registry._areas).isNotHidden().orderBy(['order', 'name'], 'asc').toList();
186186

187187
// Sort views and domains by order first and then by title.

src/utilities/RegistryFilter.ts

Lines changed: 33 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@ import { DeviceRegistryEntry } from '../types/homeassistant/data/device_registry
55
import { EntityCategory, EntityRegistryEntry } from '../types/homeassistant/data/entity_registry';
66
import { RegistryEntry, StrategyConfig } from '../types/strategy/strategy-generics';
77
import { logMessage, lvlWarn } from './debug';
8+
import { compareValues, getSortPriority } from './auxiliaries';
89

910
/**
1011
* A class for filtering and sorting arrays of Home Assistant's registry entries.
1112
*
1213
* Supports chaining for building complex filter queries.
1314
*
1415
* @template T The specific type of RegistryEntry being filtered.
15-
* @template K - A property key of T.
16+
* @template K A property name of the specific entry-type.
1617
*/
1718
class RegistryFilter<T extends RegistryEntry, K extends keyof T = keyof T> {
1819
private readonly entries: T[];
@@ -329,86 +330,55 @@ class RegistryFilter<T extends RegistryEntry, K extends keyof T = keyof T> {
329330
}
330331

331332
/**
332-
* Sorts the entries based on the specified keys in priority order.
333+
* Sorts the entries based on the specified keys in order of priority.
333334
*
334-
* @param {Array<keyof T>} keys - Array of property keys to sort by, in order of priority.
335-
* @param {'asc' | 'desc'} [direction='asc'] - Sort direction.
336-
* @returns {RegistryFilter<T>} A new RegistryFilter instance with sorted entries.
337-
* @template T - The type of registry entry
335+
* @param {K[]} propertyNames An array of property names to sort by, in order of priority.
336+
* @param {'asc' | 'desc'} [direction='asc'] The sort direction: 'asc' for ascending, 'desc' for descending.
337+
*
338+
* @returns {RegistryFilter<T>} A new RegistryFilter instance with the entries sorted.
339+
* @remarks
340+
* - Each property in `propertyNames` is used in order to break ties from the previous property.
341+
* - Special values like `null`, `undefined`, `Infinity`, and `-Infinity` are handled via `getSortPriority`.
342+
* - If all specified properties are equal between two entries, their order is considered equivalent (`return 0`).
338343
*/
339-
orderBy(keys: K[], direction: 'asc' | 'desc' = 'asc'): RegistryFilter<T> {
340-
// Helper to get the first defined value from an entry for the given keys.
341-
const getValue = (entry: T, keys: K[]): unknown => {
342-
for (const key of keys) {
343-
const value = entry[key];
344-
if (value !== null && value !== undefined) {
345-
return value;
346-
}
347-
}
348-
return undefined;
349-
};
350-
351-
// Assign sort priorities for special values.
352-
const getSortValue = (value: unknown): [number, unknown] => {
353-
switch (value) {
354-
case -Infinity:
355-
return [0, 0]; // First.
356-
case undefined:
357-
case null:
358-
return [2, 0]; // In between.
359-
case Infinity:
360-
return [3, 0]; // Last.
361-
default:
362-
return [1, value]; // Normal value comparison.
363-
}
364-
};
344+
orderBy(propertyNames: K[], direction: 'asc' | 'desc' = 'asc'): RegistryFilter<T> {
345+
const sortDirection = direction === 'asc' ? 1 : -1;
365346

366-
// Create a new array to avoid mutating the original.
347+
// Sort the entries by creating a new array to avoid mutating the original.
367348
const sortedEntries = [...this.entries].sort((a, b) => {
368-
const sortDirection = direction === 'asc' ? 1 : -1;
349+
for (const name of propertyNames) {
350+
const propertyValueA = a[name];
351+
const propertyValueB = b[name];
369352

370-
// Get the first defined value for each entry using the provided keys
371-
const valueA = getValue(a, keys);
372-
const valueB = getValue(b, keys);
353+
// If the values are equal, continue to the next property.
354+
if (propertyValueA === propertyValueB) continue;
373355

374-
// If values are strictly equal, they're in the same position.
375-
if (valueA === valueB) {
376-
return 0;
377-
}
378-
379-
// Get sort priorities and comparable values
380-
const [priorityA, comparableA] = getSortValue(valueA);
381-
const [priorityB, comparableB] = getSortValue(valueB);
382-
383-
// First, compare by priority (handles special values).
384-
if (priorityA !== priorityB) {
385-
return (priorityA - priorityB) * sortDirection;
386-
}
356+
// Determine the sorting priority of the values.
357+
const [priorityA, comparableA] = getSortPriority(propertyValueA);
358+
const [priorityB, comparableB] = getSortPriority(propertyValueB);
387359

388-
// For same priority, compare the actual values.
389-
// Handle undefined/null cases
390-
if (comparableA == null) {
391-
return 1;
392-
}
360+
// Compare priorities first.
361+
if (priorityA !== priorityB) {
362+
return (priorityA - priorityB) * sortDirection;
363+
}
393364

394-
if (comparableB == null) {
395-
return -1;
396-
}
365+
// For the same priority, compare the values themselves.
366+
const result = compareValues(comparableA, comparableB, direction);
367+
if (result !== 0) return result;
397368

398-
// String comparison.
399-
if (typeof comparableA === 'string' && typeof comparableB === 'string') {
400-
return comparableA.localeCompare(comparableB) * sortDirection;
369+
// The values are equal.
401370
}
402371

403-
// Numeric/other comparison.
404-
return (comparableA < comparableB ? -1 : 1) * sortDirection;
372+
// The priorities and the values are equal.
373+
return 0;
405374
});
406375

407376
// Create a new filter with the sorted entries.
408377
const newFilter = new RegistryFilter(sortedEntries);
409378

410379
// Copy over existing filters.
411380
newFilter.filters = [...this.filters];
381+
412382
return newFilter;
413383
}
414384

src/utilities/auxiliaries.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,75 @@ export function deepClone<T>(obj: T): T {
3636
return obj;
3737
}
3838
}
39+
40+
/**
41+
* Compares two values according to a sort direction.
42+
*
43+
* Comparison rules:
44+
* 1. `null` and `undefined` are ordered first and second respectively.
45+
* 2. Strings are compared using locale-aware comparison with numeric sorting.
46+
* 3. Numbers and booleans are compared naturally.
47+
* 4. Any other types are considered equal (i.e., do not affect sort order).
48+
*
49+
* Returns:
50+
* - A negative number if `a` should come **before** `b` in the specified `direction`.
51+
* - A positive number if `a` should come **after** `b` in the specified `direction`.
52+
* - `0` if `a` and `b` are considered equal for sorting.
53+
*
54+
* This implementation ensures a deterministic and stable sort for `Array.prototype.sort`.
55+
*
56+
* @param {unknown} a The first value.
57+
* @param {unknown} b The second value.
58+
* @param {'asc' | 'desc'} [direction='asc'] The sort direction.
59+
*
60+
* @returns {number} The comparison result.
61+
*/
62+
export function compareValues(a: unknown, b: unknown, direction: 'asc' | 'desc' = 'asc'): number {
63+
// Values are equal.
64+
if (a === b) return 0;
65+
66+
const sortDirection = direction === 'asc' ? 1 : -1;
67+
68+
// Handle null / undefined explicitly.
69+
if (a == null || b == null) {
70+
return (a == null ? -1 : 1) * sortDirection;
71+
}
72+
73+
// Strings.
74+
if (typeof a === 'string' && typeof b === 'string') {
75+
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) * sortDirection;
76+
}
77+
78+
// Numbers / booleans.
79+
const isNumeric = (val: unknown) => typeof val === 'number' || typeof val === 'boolean';
80+
81+
if (isNumeric(a) && isNumeric(b)) {
82+
return (+a - +b) * sortDirection;
83+
}
84+
85+
// All other types: treat as equal.
86+
return 0;
87+
}
88+
89+
/**
90+
* Determines a sorting priority for a value.
91+
*
92+
* A helper function that prioritizes an object's property value for sorting purposes.
93+
*
94+
* Priority levels for the evaluated value:
95+
* 0. `-Infinity` (force to top)
96+
* 1. standard values (string, number, boolean).
97+
* 2. `null` or `undefined` (force to bottom).
98+
* 3. `+Infinity` (absolute bottom).
99+
*
100+
* @template V The type of the value.
101+
* @param {V | number | string} value The value to evaluate.
102+
* @returns {[number, V | number | string]} Tuple of [priority, comparable value].
103+
*/
104+
export function getSortPriority<V>(value: V | number | string): [number, V | number | string] {
105+
if (value === -Infinity) return [0, 0];
106+
if (value == null) return [2, 0];
107+
if (value === Infinity) return [3, 0];
108+
109+
return [1, value];
110+
}

0 commit comments

Comments
 (0)