- TypeScript 48.8%
- JavaScript 46.5%
- HTML 3.7%
- CSS 1%
|
All checks were successful
ci / check (push) Successful in 11s
Replace the locate buttons and toast with a row of clickable nameplates floating over the map. Click flies to a person, double click follows them, and clicking a followed person's nameplate or panning the map stops following. Markers are now just the avatar, people show their zone, and the Leaflet zoom controls are removed. Adds DOM test setup to the map workspace. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6WV37dDVNs4Zhq7RQB4ku |
||
|---|---|---|
| .devcontainer | ||
| .forgejo/workflows | ||
| dashboards/map | ||
| packages/ui | ||
| scripts | ||
| .env.example | ||
| .gitignore | ||
| .prettierrc | ||
| bun.lock | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| Dockerfile.dev | ||
| Dockerfile.map | ||
| eslint.config.js | ||
| package.json | ||
| README.md | ||
| tsconfig.base.json | ||
| tsconfig.json | ||
HA dashboards
A bun workspace monorepo of dashboards for Home Assistant, each delivered as a panel_custom sidebar panel. Conventions
for contributors (and Claude) live in CLAUDE.md.
Layout
.
├── packages/ui/ shared code: HA types, definePanel, hooks, Tailwind theme, Vite helpers, dev harness
├── dashboards/map/ one workspace per dashboard
├── scripts/ run.ts (root script dispatcher) and deploy.ts (hashed copy into HA's www)
├── .forgejo/ CI workflows
├── .devcontainer/ VS Code dev container config
├── Dockerfile.dev dev container image; copies every workspace manifest
├── docker-compose.yml dev container; one anonymous node_modules volume per workspace
├── .env.example copy to .env: dev container user/UID/GID, Forgejo token, HA_WWW_DIR, VITE_PORT,
│ optional live-HA URL and token
└── .prettierrc, eslint.config.js, tsconfig.base.json shared tooling config
Tests are colocated with the code (*.test.ts(x)); packages/ui also keeps shared test setup in packages/ui/test/
and scripts/ has run.test.ts and deploy.test.ts. packages/ui/README.md covers theme tokens, definePanel and
the dev harness internals.
Dev loop
There are two ways to check a dashboard.
Dev harness (fast, no HA needed). bun run dev <dashboard> starts Vite with HMR and mounts the panel in the shared
harness. With VITE_HA_URL and VITE_HA_TOKEN set in the repo-root .env it connects to a real Home Assistant;
leave them blank and it uses the dashboard's src/mock.ts fixtures. Append ?mock to the URL to force mock mode.
The dev server uses port 5173 with strictPort, so only one dashboard can run at a time (the dev container publishes
it on VITE_PORT).
docker compose up -d map runs the same harness in a container (Dockerfile.map) with the repo bind-mounted for HMR;
open http://localhost:${MAP_PORT:-5176}. It reads VITE_HA_URL / VITE_HA_TOKEN from .env, so blank means mock mode.
Inside HA. bun run build <dashboard> --watch keeps dist-panel/ current while you edit. The watch loop does not
deploy, so run bun run deploy <dashboard> (or copy the file) whenever you want HA to pick the change up; see
Registering the panel for the follow-up steps.
Tests
bun run test runs bun test in every workspace (bun run test map for one); unscoped, it also
runs bun test scripts.
A workspace that needs DOM tests must set up its own preload; see
packages/ui/README.md.
Adding a dashboard
This walks through adding a dashboard called foo, with the panel element ha-foo-panel. Replace foo, Foo and
the sidebar details with your own. Dashboard names are lowercase letters, digits and hyphens. Copy from
dashboards/map when in doubt.
1. Create the workspace files
Create dashboards/foo/ with these files.
package.json (add whatever extra dependencies the dashboard needs, as dashboards/map does for Leaflet):
{
"name": "@ha-dashboards/foo",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --configLoader runner",
"build": "vite build --configLoader runner --config vite.panel.config.ts",
"build:harness": "vite build --configLoader runner",
"preview": "vite preview --configLoader runner",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
"test": "bun test --pass-with-no-tests"
},
"dependencies": {
"@ha-dashboards/ui": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/bun": "^1.4.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.1.0",
"vite": "^6.1.0"
}
}
tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "types": ["vite/client", "bun"] },
"include": ["src"]
}
tsconfig.node.json (both Vite configs must be listed here):
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"types": ["node"]
},
"include": ["vite.config.ts", "vite.panel.config.ts"]
}
index.html (the dev harness page):
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HA Foo — Dev Preview</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
vite.config.ts (dev server only; not what gets deployed):
import { defineConfig } from 'vite';
import { dashboardPlugins } from '@ha-dashboards/ui/vite';
export default defineConfig(({ command }) => ({
plugins: dashboardPlugins(),
// Read VITE_HA_URL / VITE_HA_TOKEN from the repo-root .env, for the dev server only.
envDir: command === 'build' ? undefined : '../..',
server: { host: true, port: 5173, strictPort: true },
}));
vite.panel.config.ts (the deployable build; it always outputs to dist-panel/, which deploy reads):
import { defineConfig } from 'vite';
import { panelBuildConfig } from '@ha-dashboards/ui/vite';
import { fileURLToPath } from 'node:url';
import { PANEL_NAME } from './src/panel';
export default defineConfig(
panelBuildConfig({
entry: fileURLToPath(new URL('./src/element.tsx', import.meta.url)),
panelName: PANEL_NAME,
}),
);
The build is a Vite library-mode build to a single ES module (dist-panel/ha-foo-panel.js) with React bundled in,
because the HA page has no shared React runtime to resolve against.
src/panel.ts (the one place the element name lives):
/** The custom element name: registered by element.tsx, used as the output file name and in `panel_custom`. */
export const PANEL_NAME = 'ha-foo-panel';
src/FooPanel.tsx (the panel component; read entities with useHass()):
import { useHass } from '@ha-dashboards/ui/panel';
export default function FooPanel() {
const hass = useHass();
return <div className='p-4 text-fg'>Entities: {Object.keys(hass.states).length}</div>;
}
src/element.tsx (registers the custom element; side effects only, so fast refresh works):
import { definePanel } from '@ha-dashboards/ui/panel';
import FooPanel from './FooPanel';
import { PANEL_NAME } from './panel';
import styles from './index.css?inline';
definePanel(PANEL_NAME, FooPanel, { styles });
src/main.tsx (the dev harness entry):
import { mountHarness } from '@ha-dashboards/ui/dev';
import FooPanel from './FooPanel';
import { mock } from './mock';
import './index.css';
mountHarness({ Panel: FooPanel, mock, env: import.meta.env });
src/mock.ts (fixtures used when no live HA is configured):
import { makeEntity, type MockHassOptions } from '@ha-dashboards/ui/dev';
export const mock: MockHassOptions = {
entities: [makeEntity('sensor.example', '42')],
};
src/index.css (Tailwind plus the shared theme; add third-party CSS imports here):
@import 'tailwindcss';
@import '@ha-dashboards/ui/theme.css';
@source not './**/*.test.{ts,tsx}';
2. Register the workspace with the dev container
In Dockerfile.dev, add a COPY line next to the other workspace manifests, so bun install can resolve the workspace:
COPY --chown=${DEV_UID}:${DEV_GID} dashboards/foo/package.json ./dashboards/foo/
In docker-compose.yml, add an anonymous node_modules volume next to the existing ones under dev.volumes:
- /app/dashboards/foo/node_modules
3. Install and commit the lockfile
Run bun install at the repo root and commit the updated bun.lock. CI installs with --frozen-lockfile, so a stale
lockfile fails the build.
4. Add a test
Add at least one colocated test, for example dashboards/foo/src/mock.test.ts asserting the fixtures. Prefer testing
pure logic. If the dashboard needs DOM tests, add a bunfig.toml preload
([test] preload = ["../../packages/ui/test/dom.ts", "../../packages/ui/test/setup.ts"]), add
../../packages/ui/test/jest-dom.d.ts to the tsconfig.json include, and add @testing-library/react and
@testing-library/dom to devDependencies; see packages/ui/README.md.
5. Run it, deploy it, register it
bun run dev fooand open the printed URL (mock data unless.envhas a live HA); from the host, usehttp://localhost:$VITE_PORT.bun run typecheck fooandbun run build footo confirm the bundle builds.bun run deploy fooand note themodule_urlit prints.- Add the
panel_customentry to../ha/configuration.yaml(outside this repo; see Registering the panel) withname: ha-foo-panel, then check the config and restart HA.
What you get for free
The root scripts discover workspaces from packages/* and dashboards/*, so a new dashboard is automatically picked
up by bun run build, typecheck, test, lint, lint:fix, format and format:check, and therefore by CI.
No root config changes are needed.
Known map-specific leftovers
These are not generalised yet; rename them when you add a second dashboard:
- the compose project/container name
ha-map(COMPOSE_PROJECT_NAMEin.env.example, thecontainer_namedefault indocker-compose.ymland theCMDready line inDockerfile.dev); .devcontainer/devcontainer.json: the name "HA Map (...)" and theha-map-vscode-servervolume;- the
node_modulesvolume comment inDockerfile.dev(names onlydashboards/map); - the
bun run deploy mapcomments indocker-compose.yml; - the "HA Map" dev-preview title in
dashboards/map/index.html.
Map markers and header tiles are not part of this list: they are discovered from every person.* entity with
coordinates.
Commands
Run from the repo root.
| Command | What it does |
|---|---|
bun run dev <dashboard> |
Dev harness with HMR (live or mock HA). |
bun run build [dashboard] |
Builds the panel bundle (every dashboard when no name is given). |
bun run build <dashboard> --watch |
Rebuilds the bundle on every source change. |
bun run deploy <dashboard> |
Builds, then copies the bundle to $HA_WWW_DIR/<dashboard>/<panel>.<hash>.js. |
bun run typecheck, test, lint, format:check |
Checks across every workspace and scripts/. |
bun run lint:fix, format |
Auto-fix lint problems / rewrite files with Prettier. |
Flags such as --watch or --mode staging are passed through to the dashboard's script, so put the workspace name
before any flags (bun run build map --watch). deploy, lint, lint:fix, format and format:check reject flags.
CI
.forgejo/workflows/ci.yml runs on every pull request and every push to main, on the ubuntu-latest runner of the
homelab Forgejo instance. It installs with bun install --frozen-lockfile, then runs format:check, lint,
typecheck, test and build as separate steps, so a failing run shows which gate broke. Each is the root script from
the table above, so new dashboards are covered automatically. No secrets or variables are needed.
Deploying
HA_WWW_DIR is Home Assistant's config/www folder on the host. Set it in the repo-root .env (see
.env.example). deploy runs from the repo root so Bun loads that file.
The deployed filename carries the first 8 hex characters of the bundle's sha256, so a changed bundle is a new URL and
HA and the browser cannot serve stale JavaScript. deploy removes older hashed files of the same panel and prints the
exact module_url. The newest previous hashed file is kept, because ../ha/configuration.yaml still points at it until
you update the URL and restart HA; only older hashes are removed, and only files named <panel>.<8 hex>.js.
Redeploying unchanged code produces the same file and the same URL.
Home Assistant serves config/www at /local/, so www/map/ha-map-panel.1a2b3c4d.js is
/local/map/ha-map-panel.1a2b3c4d.js.
Registering the panel
Add the panel to ../ha/configuration.yaml (outside this repo), using the module_url that
bun run deploy <dashboard> printed:
panel_custom:
- name: ha-map-panel
sidebar_title: Map
sidebar_icon: mdi:map
module_url: /local/map/ha-map-panel.<hash>.js # exact URL printed by `bun run deploy map`
require_admin: false
namemust match the element name passed todefinePanel.require_admin: truehides the panel from non-admin users.
Because the YAML contains the hashed URL, the workflow after a code change is:
bun run deploy <dashboard>.- If the printed
module_urldiffers from the one in../ha/configuration.yaml, update it there. - Check the config (Developer tools > YAML > Check configuration), then restart Home Assistant.
panel_customis not reloadable, so a restart is required whenever the URL changes. An unchanged bundle needs nothing. - Hard-refresh the browser (Ctrl+Shift+R) so it drops the old module.
Migrating from the old deploy
The previous deploy script copied the bundle to $HA_WWW_DIR/ha-map/ha-map-panel.js. The new path is
$HA_WWW_DIR/map/ with a hashed name. Delete the old $HA_WWW_DIR/ha-map/ folder from the install, and replace
the /local/ha-map/ha-map-panel.js URL in ../ha/configuration.yaml with the one bun run deploy map prints.