mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
Split sensors.md (1,693 lines) into 7 focused pages: - sensors-overview: binary sensors, core price, min/max, diagnostics - sensors-average: median/mean, automation examples, key attributes - sensors-ratings-levels: 3-level ratings + 5-level system - sensors-volatility: CV formula, 4 sensors, configuration - sensors-trends: outlook, trajectory, current/next, decision guide - sensors-timing: period timing state diagram + examples - sensors-energy-tax: energy/tax breakdown + use cases Extract relaxation deep-dive from period-calculation.md into dedicated period-relaxation.md. Remove duplicate ApexCharts section from automation-examples.md (cross-references chart-examples.md). Reorganize sidebar into semantic categories: - Sensors (7 pages), Price Periods (2), Dashboards & Charts (4), Reference (sensor-reference + actions) Update all cross-references across 10 pages, EntitySearch DOC_NAMES, and generator template for new page slugs. Impact: Users can find information faster with shorter, focused pages and a clearer navigation structure. No content was removed — only reorganized and deduplicated.
560 lines
19 KiB
TypeScript
560 lines
19 KiB
TypeScript
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
|
|
|
interface RowEntry {
|
|
/** The entity-anchor id (e.g. "ref-current_interval_price") */
|
|
anchorId: string;
|
|
/** The translation_key / entity ID suffix */
|
|
key: string;
|
|
/** English name (first translated name) */
|
|
englishName: string;
|
|
/** All translated names (for display in results) */
|
|
translatedNames: string[];
|
|
/** All searchable text from the row (names in all languages, key) */
|
|
searchText: string;
|
|
/** The <tr> element */
|
|
row: HTMLTableRowElement;
|
|
/** Platform heading (e.g. "Sensors", "Binary Sensors") */
|
|
platform: string;
|
|
/** Doc page slugs that reference this entity (from data-refs attribute) */
|
|
docRefs: string[];
|
|
/** Name cells (columns between key and default) for match highlighting */
|
|
nameCells: HTMLTableCellElement[];
|
|
/** Original innerHTML of name cells (for restoring after highlighting) */
|
|
originalNameHTML: string[];
|
|
}
|
|
|
|
const MAX_RESULTS = 12;
|
|
|
|
/** Display names for doc page back-links (from data-refs attribute). */
|
|
const DOC_NAMES: Record<string, string> = {
|
|
'sensors-overview': 'Sensors Overview',
|
|
'sensors-average': 'Average Sensors',
|
|
'sensors-ratings-levels': 'Ratings & Levels',
|
|
'sensors-volatility': 'Volatility',
|
|
'sensors-trends': 'Trends',
|
|
'sensors-timing': 'Timing',
|
|
'sensors-energy-tax': 'Energy & Tax',
|
|
configuration: 'Configuration',
|
|
'period-calculation': 'Period Calculation',
|
|
'period-relaxation': 'Relaxation',
|
|
'automation-examples': 'Automation Examples',
|
|
actions: 'Actions',
|
|
};
|
|
|
|
/** Platform filter chips. `match` is tested with startsWith against h2 text. */
|
|
const PLATFORM_CHIPS = [
|
|
{label: 'Sensors', match: 'Sensors'},
|
|
{label: 'Binary Sensors', match: 'Binary Sensors'},
|
|
{label: 'Numbers', match: 'Number Entities'},
|
|
{label: 'Switches', match: 'Switch Entities'},
|
|
];
|
|
|
|
/**
|
|
* Highlight `needle` inside `html` by wrapping matches in <mark> tags.
|
|
* Only replaces inside text nodes (outside HTML tags) to keep markup intact.
|
|
*/
|
|
function highlightHTML(html: string, needle: string): string {
|
|
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const re = new RegExp(`(${escaped})`, 'gi');
|
|
return html.replace(/(<[^>]*>)|([^<]+)/g, (_m, tag: string, text: string) => {
|
|
if (tag) return tag;
|
|
return text.replace(re, '<mark class="entity-match">$1</mark>');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Live-filtering search bar for the sensor-reference page.
|
|
*
|
|
* Scans all `.entity-anchor` spans on mount to build an index of
|
|
* entity keys and translated names. Typing filters the tables in
|
|
* real-time and shows a clickable result list to jump to entries.
|
|
*/
|
|
export default function EntitySearch(): React.ReactElement {
|
|
const [query, setQuery] = useState('');
|
|
const [total, setTotal] = useState(0);
|
|
const [matchCount, setMatchCount] = useState(0);
|
|
const [matches, setMatches] = useState<RowEntry[]>([]);
|
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
const [activeChip, setActiveChip] = useState<string | null>(null);
|
|
const entriesRef = useRef<RowEntry[]>([]);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const resultsRef = useRef<HTMLUListElement>(null);
|
|
|
|
// ── Build the search index on mount ──────────────────────────
|
|
useEffect(() => {
|
|
const anchors = document.querySelectorAll<HTMLSpanElement>('.entity-anchor');
|
|
const entries: RowEntry[] = [];
|
|
|
|
anchors.forEach((anchor) => {
|
|
const row = anchor.closest('tr');
|
|
if (!row) return;
|
|
|
|
const anchorId = anchor.id;
|
|
const key = anchorId.replace(/^ref-/, '');
|
|
|
|
// Determine platform from closest h2 above this table
|
|
let platform = '';
|
|
const table = row.closest('table');
|
|
if (table) {
|
|
let el = table.previousElementSibling;
|
|
while (el) {
|
|
if (el.tagName === 'H2') {
|
|
platform = el.textContent?.trim() ?? '';
|
|
break;
|
|
}
|
|
el = el.previousElementSibling;
|
|
}
|
|
}
|
|
|
|
// Doc back-links from data attribute (set by generator)
|
|
const refsAttr = anchor.getAttribute('data-refs');
|
|
const docRefs = refsAttr ? refsAttr.split(',').filter(Boolean) : [];
|
|
|
|
// Collect text from all cells + store name cells for highlighting
|
|
const cells = row.querySelectorAll('td');
|
|
const texts: string[] = [key];
|
|
const translatedNames: string[] = [];
|
|
const nameCells: HTMLTableCellElement[] = [];
|
|
const originalNameHTML: string[] = [];
|
|
|
|
cells.forEach((cell, i) => {
|
|
const text = cell.textContent?.trim();
|
|
if (text && text !== '✅' && text !== '❌') {
|
|
texts.push(text);
|
|
}
|
|
// Name cells = columns between key (0) and default (last)
|
|
if (i > 0 && i < cells.length - 1) {
|
|
nameCells.push(cell);
|
|
originalNameHTML.push(cell.innerHTML);
|
|
const t = cell.textContent?.trim();
|
|
if (t) translatedNames.push(t);
|
|
}
|
|
});
|
|
|
|
// ── Inject copy-entity-ID button ──
|
|
const firstCell = cells[0];
|
|
if (firstCell && !firstCell.querySelector('.entity-copy-btn')) {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'entity-copy-btn';
|
|
btn.title = 'Copy entity ID suffix';
|
|
btn.setAttribute('aria-label', `Copy ${key}`);
|
|
btn.textContent = '\u29C9'; // ⧉ overlapping squares
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
navigator.clipboard.writeText(key).then(() => {
|
|
btn.textContent = '\u2713'; // ✓
|
|
btn.classList.add('copied');
|
|
setTimeout(() => {
|
|
btn.textContent = '\u29C9';
|
|
btn.classList.remove('copied');
|
|
}, 1500);
|
|
});
|
|
});
|
|
firstCell.appendChild(btn);
|
|
}
|
|
|
|
// ── Inject doc back-links ──
|
|
if (docRefs.length > 0 && firstCell && !firstCell.querySelector('.entity-back-links')) {
|
|
const span = document.createElement('span');
|
|
span.className = 'entity-back-links';
|
|
docRefs.forEach((ref) => {
|
|
const slug = ref.split('#')[0];
|
|
const a = document.createElement('a');
|
|
a.href = ref;
|
|
a.className = 'entity-back-link';
|
|
a.title = DOC_NAMES[slug] ?? slug;
|
|
a.setAttribute('aria-label', `View in: ${DOC_NAMES[slug] ?? slug}`);
|
|
a.textContent = '\uD83D\uDCD6'; // 📖
|
|
span.appendChild(a);
|
|
});
|
|
firstCell.appendChild(span);
|
|
}
|
|
|
|
entries.push({
|
|
anchorId,
|
|
key,
|
|
englishName: translatedNames[0] ?? key,
|
|
translatedNames,
|
|
searchText: texts.join(' ').toLowerCase(),
|
|
row,
|
|
platform,
|
|
docRefs,
|
|
nameCells,
|
|
originalNameHTML,
|
|
});
|
|
});
|
|
|
|
entriesRef.current = entries;
|
|
setTotal(entries.length);
|
|
setMatchCount(entries.length);
|
|
}, []);
|
|
|
|
// ── Jump to #ref-* hash on arrival / hash change ─────────────
|
|
useEffect(() => {
|
|
const entries = entriesRef.current;
|
|
if (entries.length === 0) return;
|
|
|
|
const jumpToHash = () => {
|
|
const hash = window.location.hash;
|
|
if (!hash.startsWith('#ref-')) return;
|
|
|
|
const key = hash.slice(5);
|
|
const entry = entries.find((e) => e.key === key);
|
|
if (!entry) return;
|
|
|
|
document.querySelectorAll('.entity-search-jump-highlight').forEach((el) => {
|
|
el.classList.remove('entity-search-jump-highlight');
|
|
});
|
|
const anchor = document.getElementById(entry.anchorId);
|
|
if (anchor) {
|
|
requestAnimationFrame(() => {
|
|
anchor.scrollIntoView({behavior: 'smooth', block: 'center'});
|
|
void entry.row.offsetWidth;
|
|
entry.row.classList.add('entity-search-jump-highlight');
|
|
});
|
|
}
|
|
};
|
|
|
|
jumpToHash();
|
|
window.addEventListener('hashchange', jumpToHash);
|
|
return () => window.removeEventListener('hashchange', jumpToHash);
|
|
}, [total]);
|
|
|
|
// ── Global "/" shortcut to focus the search input ────────────
|
|
useEffect(() => {
|
|
const handleSlash = (e: KeyboardEvent) => {
|
|
if (
|
|
e.key === '/' &&
|
|
!e.ctrlKey &&
|
|
!e.metaKey &&
|
|
!e.altKey &&
|
|
!(e.target instanceof HTMLInputElement) &&
|
|
!(e.target instanceof HTMLTextAreaElement) &&
|
|
!(e.target instanceof HTMLSelectElement)
|
|
) {
|
|
e.preventDefault();
|
|
inputRef.current?.focus();
|
|
inputRef.current?.scrollIntoView({behavior: 'smooth', block: 'nearest'});
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handleSlash);
|
|
return () => document.removeEventListener('keydown', handleSlash);
|
|
}, []);
|
|
|
|
// ── Core filtering logic ─────────────────────────────────────
|
|
const applyFilter = useCallback((search: string, chip: string | null) => {
|
|
const entries = entriesRef.current;
|
|
const needle = search.toLowerCase().trim();
|
|
let matchCountLocal = 0;
|
|
const matchedEntries: RowEntry[] = [];
|
|
const sectionsWithMatches = new Set<Element>();
|
|
|
|
// Restore previous match highlights
|
|
entries.forEach((entry) => {
|
|
entry.nameCells.forEach((cell, i) => {
|
|
if (cell.innerHTML !== entry.originalNameHTML[i]) {
|
|
cell.innerHTML = entry.originalNameHTML[i];
|
|
}
|
|
});
|
|
});
|
|
|
|
entries.forEach((entry) => {
|
|
// Platform chip filter
|
|
const chipMatch = !chip || entry.platform.startsWith(chip);
|
|
// Text search filter
|
|
const textMatch = !needle || entry.searchText.includes(needle);
|
|
const isMatch = chipMatch && textMatch;
|
|
|
|
if (isMatch) {
|
|
matchCountLocal++;
|
|
matchedEntries.push(entry);
|
|
entry.row.classList.remove('entity-search-hidden');
|
|
entry.row.classList.add('entity-search-match');
|
|
|
|
// Highlight matched text in name cells
|
|
if (needle) {
|
|
entry.nameCells.forEach((cell, i) => {
|
|
cell.innerHTML = highlightHTML(entry.originalNameHTML[i], needle);
|
|
});
|
|
}
|
|
|
|
const table = entry.row.closest('table');
|
|
if (table) {
|
|
let prev = table.previousElementSibling;
|
|
while (prev && prev.tagName !== 'H3' && prev.tagName !== 'H2') {
|
|
prev = prev.previousElementSibling;
|
|
}
|
|
if (prev) sectionsWithMatches.add(prev);
|
|
}
|
|
} else {
|
|
entry.row.classList.add('entity-search-hidden');
|
|
entry.row.classList.remove('entity-search-match');
|
|
}
|
|
});
|
|
|
|
const isActive = !!(needle || chip);
|
|
if (isActive) {
|
|
document.querySelectorAll('.markdown h3, .markdown h2').forEach((heading) => {
|
|
if (heading.textContent?.includes('How to Find')) return;
|
|
const hasMatch = sectionsWithMatches.has(heading);
|
|
|
|
if (heading.tagName === 'H3') {
|
|
heading.classList.toggle('entity-search-section-hidden', !hasMatch);
|
|
let el = heading.nextElementSibling;
|
|
while (el && el.tagName !== 'TABLE' && el.tagName !== 'H3' && el.tagName !== 'H2') {
|
|
el.classList.toggle('entity-search-section-hidden', !hasMatch);
|
|
el = el.nextElementSibling;
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
document.querySelectorAll('.entity-search-section-hidden').forEach((el) => {
|
|
el.classList.remove('entity-search-section-hidden');
|
|
});
|
|
}
|
|
|
|
setMatchCount(matchCountLocal);
|
|
setMatches(isActive ? matchedEntries : []);
|
|
setActiveIndex(-1);
|
|
}, []);
|
|
|
|
const scrollToEntry = useCallback((entry: RowEntry) => {
|
|
document.querySelectorAll('.entity-search-jump-highlight').forEach((el) => {
|
|
el.classList.remove('entity-search-jump-highlight');
|
|
});
|
|
|
|
const anchor = document.getElementById(entry.anchorId);
|
|
if (anchor) {
|
|
history.pushState(null, '', `#${entry.anchorId}`);
|
|
anchor.scrollIntoView({behavior: 'smooth', block: 'center'});
|
|
void entry.row.offsetWidth;
|
|
entry.row.classList.add('entity-search-jump-highlight');
|
|
}
|
|
}, []);
|
|
|
|
const handleChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const value = e.target.value;
|
|
setQuery(value);
|
|
applyFilter(value, activeChip);
|
|
},
|
|
[applyFilter, activeChip],
|
|
);
|
|
|
|
const handleClear = useCallback(() => {
|
|
setQuery('');
|
|
setActiveChip(null);
|
|
applyFilter('', null);
|
|
inputRef.current?.focus();
|
|
}, [applyFilter]);
|
|
|
|
// Click anywhere outside the search bar → reset all filters
|
|
useEffect(() => {
|
|
if (query.trim().length === 0 && activeChip === null) return;
|
|
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
const target = e.target as Node;
|
|
// Don't reset if clicking inside the search container
|
|
if (containerRef.current?.contains(target)) return;
|
|
// Don't reset if clicking inside an entity table or its buttons
|
|
if ((target as Element).closest?.('.entity-copy-btn, .entity-back-link, table')) return;
|
|
|
|
setQuery('');
|
|
setActiveChip(null);
|
|
applyFilter('', null);
|
|
};
|
|
|
|
document.addEventListener('click', handleClickOutside);
|
|
return () => document.removeEventListener('click', handleClickOutside);
|
|
}, [query, activeChip, applyFilter]);
|
|
|
|
const handleChipClick = useCallback(
|
|
(chipMatch: string) => {
|
|
const next = chipMatch === activeChip ? null : chipMatch;
|
|
setActiveChip(next);
|
|
applyFilter(query, next);
|
|
},
|
|
[applyFilter, query, activeChip],
|
|
);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Escape') {
|
|
handleClear();
|
|
return;
|
|
}
|
|
|
|
const visibleMatches = matches.slice(0, MAX_RESULTS);
|
|
if (visibleMatches.length === 0) return;
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setActiveIndex((prev) => {
|
|
const next = prev < visibleMatches.length - 1 ? prev + 1 : 0;
|
|
resultsRef.current?.children[next]?.scrollIntoView({block: 'nearest'});
|
|
return next;
|
|
});
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setActiveIndex((prev) => {
|
|
const next = prev > 0 ? prev - 1 : visibleMatches.length - 1;
|
|
resultsRef.current?.children[next]?.scrollIntoView({block: 'nearest'});
|
|
return next;
|
|
});
|
|
} else if (e.key === 'Enter' && activeIndex >= 0 && activeIndex < visibleMatches.length) {
|
|
e.preventDefault();
|
|
scrollToEntry(visibleMatches[activeIndex]);
|
|
}
|
|
},
|
|
[handleClear, matches, activeIndex, scrollToEntry],
|
|
);
|
|
|
|
/** Find the best matching translated name for highlighting in dropdown */
|
|
const getMatchingName = useCallback(
|
|
(entry: RowEntry, needle: string): string | null => {
|
|
if (!needle) return null;
|
|
const lower = needle.toLowerCase();
|
|
// Check non-English names first (user is likely searching in their language)
|
|
for (let i = 1; i < entry.translatedNames.length; i++) {
|
|
if (entry.translatedNames[i].toLowerCase().includes(lower)) {
|
|
return entry.translatedNames[i];
|
|
}
|
|
}
|
|
// Then English
|
|
if (entry.englishName.toLowerCase().includes(lower)) {
|
|
return null; // Already shown as primary
|
|
}
|
|
return null;
|
|
},
|
|
[],
|
|
);
|
|
|
|
const isFiltering = query.trim().length > 0 || activeChip !== null;
|
|
const visibleMatches = matches.slice(0, MAX_RESULTS);
|
|
const hasMore = matches.length > MAX_RESULTS;
|
|
|
|
return (
|
|
<div ref={containerRef} className="entity-search">
|
|
{/* ── Category filter chips ── */}
|
|
<div className="entity-search-chips" role="group" aria-label="Filter by platform">
|
|
{PLATFORM_CHIPS.map((chip) => (
|
|
<button
|
|
key={chip.match}
|
|
type="button"
|
|
className={`entity-search-chip${activeChip === chip.match ? ' active' : ''}`}
|
|
onClick={() => handleChipClick(chip.match)}
|
|
aria-pressed={activeChip === chip.match}
|
|
>
|
|
{chip.label}
|
|
</button>
|
|
))}
|
|
<span className="entity-search-shortcut-hint">
|
|
Press <kbd>/</kbd> to search
|
|
</span>
|
|
</div>
|
|
|
|
{/* ── Search input ── */}
|
|
<div className="entity-search-input-wrapper">
|
|
<svg
|
|
className="entity-search-icon"
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="11" cy="11" r="8" />
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
</svg>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
className="entity-search-input"
|
|
placeholder="Search entities (any language)…"
|
|
value={query}
|
|
onChange={handleChange}
|
|
onKeyDown={handleKeyDown}
|
|
aria-label="Search entities"
|
|
aria-expanded={isFiltering && visibleMatches.length > 0}
|
|
aria-controls="entity-search-results"
|
|
aria-activedescendant={
|
|
activeIndex >= 0 ? `entity-result-${activeIndex}` : undefined
|
|
}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
role="combobox"
|
|
/>
|
|
{isFiltering && (
|
|
<button
|
|
className="entity-search-clear"
|
|
onClick={handleClear}
|
|
aria-label="Clear search and filters"
|
|
type="button"
|
|
>
|
|
✕
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Results dropdown ── */}
|
|
{isFiltering && (
|
|
<div className="entity-search-results-container">
|
|
{matchCount === 0 ? (
|
|
<div className="entity-search-status">
|
|
<span className="entity-search-no-results">No matching entities found</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{query.trim().length > 0 && (
|
|
<ul
|
|
ref={resultsRef}
|
|
id="entity-search-results"
|
|
className="entity-search-results"
|
|
role="listbox"
|
|
>
|
|
{visibleMatches.map((entry, i) => {
|
|
const matchedTranslation = getMatchingName(entry, query);
|
|
return (
|
|
<li
|
|
key={entry.key}
|
|
id={`entity-result-${i}`}
|
|
className={`entity-search-result-item${i === activeIndex ? ' active' : ''}`}
|
|
role="option"
|
|
aria-selected={i === activeIndex}
|
|
onClick={() => scrollToEntry(entry)}
|
|
onMouseEnter={() => setActiveIndex(i)}
|
|
>
|
|
<span className="entity-search-result-name">
|
|
{entry.englishName}
|
|
</span>
|
|
<code className="entity-search-result-key">{entry.key}</code>
|
|
{matchedTranslation && (
|
|
<span className="entity-search-result-translation">
|
|
{matchedTranslation}
|
|
</span>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
<div className="entity-search-status">
|
|
{matchCount} of {total} entities
|
|
{hasMore && query.trim().length > 0 && (
|
|
<span className="entity-search-more">
|
|
{' '}— showing first {MAX_RESULTS}, type more to narrow down
|
|
</span>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|