Modernize tech stack

This commit is contained in:
Georgi Gardev
2026-09-15 21:19:58 +03:00
parent 8477d69bfe
commit 349c0aecf6
29 changed files with 2785 additions and 4337 deletions
-18
View File
@@ -1,18 +0,0 @@
{
"parser": "@typescript-eslint/parser", // Specifies the ESLint parser
"extends": [
"plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin
],
"parserOptions": {
"ecmaVersion": 2018, // Allows for the parsing of modern ECMAScript features
"sourceType": "module", // Allows for the use of imports
"experimentalDecorators": true
},
"rules": {
"@typescript-eslint/camelcase": 0,
"@typescript-eslint/no-non-null-assertion": 0,
"@typescript-eslint/explicit-function-return-type": [1, { "allowExpressions": true }],
"@typescript-eslint/no-namespace": 0,
},
"ignorePatterns": ["dist/", "node_modules/", "*.js"]
}
+17 -7
View File
@@ -1,4 +1,4 @@
name: 'CI' name: CI
on: on:
push: push:
@@ -12,10 +12,20 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v7
- name: Build - uses: actions/setup-node@v7
run: | with:
npm install node-version-file: .nvmrc
npm run build cache: npm
- name: Install
run: npm ci
- name: Lint
run: npm run lint
- name: Format
run: npm run format:check
- name: Typecheck
run: npm run typecheck
- name: Test - name: Test
run: npm run test run: npm test
- name: Build
run: npm run rollup
+12 -9
View File
@@ -4,21 +4,24 @@ on:
release: release:
types: [published] types: [published]
permissions:
contents: write
jobs: jobs:
release: release:
name: Build and upload name: Build and upload
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm
- name: Build time-picker-card.js - name: Build time-picker-card.js
run: | run: |
npm install npm ci
npm run build npm run build
- name: Upload to release - name: Upload to release
uses: shopify/upload-to-release@1.0.0 run: gh release upload "${{ github.event.release.tag_name }}" dist/time-picker-card.js --clobber
with: env:
name: time-picker-card.js GH_TOKEN: ${{ github.token }}
path: dist/time-picker-card.js
repo-token: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -4,3 +4,4 @@ dist/
devtools/homeassistant/* devtools/homeassistant/*
!devtools/homeassistant/configuration.yaml !devtools/homeassistant/configuration.yaml
!devtools/homeassistant/ui-lovelace.yaml !devtools/homeassistant/ui-lovelace.yaml
!devtools/homeassistant/packages/
+1
View File
@@ -0,0 +1 @@
24
+18 -17
View File
@@ -107,21 +107,22 @@ layout:
## Options ## Options
| Name | Type | Requirement | Description | Default | | Name | Type | Requirement | Description | Default |
| ----------------- | ------------ | ------------ | --------------------------------------------------------------------------------------------------------- | ------------------------ | | ----------------- | ------------- | ------------ | --------------------------------------------------------------------------------------------------------- | ------------- |
| type | string | **Required** | `custom:time-picker-card` | | | type | string | **Required** | `custom:time-picker-card` | |
| entity | string | **Required** | [Input Datetime](https://www.home-assistant.io/integrations/input_datetime/) entity with `has_time: true` | | | entity | string | **Required** | [Input Datetime](https://www.home-assistant.io/integrations/input_datetime/) entity with `has_time: true` | |
| name | string / list | **Optional** | Card name. Accepts a [structured name](#structured-names) on Home Assistant 2026.4 and later. | Entity's name | | name | string / list | **Optional** | Card name. Accepts a [structured name](#structured-names) on Home Assistant 2026.4 and later. | Entity's name |
| link_values | boolean | **Optional** | If enabled, will change hour when minutes overflow. E.g. will go from 11:55 to 12:00, instead of 11:00 | `false` | | link_values | boolean | **Optional** | If enabled, will change hour when minutes overflow. E.g. will go from 11:55 to 12:00, instead of 11:00 | `false` |
| hour_mode | `12` or `24` | **Optional** | Hour format. If `12`, card will show AM/PM picker | `24` | | hour_mode | `12` or `24` | **Optional** | Hour format. If `12`, card will show AM/PM picker | `24` |
| hour_step | number | **Optional** | Hour change when clicking arrows | `1` | | hour_step | number | **Optional** | Hour change when clicking arrows | `1` |
| minute_step | number | **Optional** | Minute change when clicking arrows | `5` | | minute_step | number | **Optional** | Minute change when clicking arrows | `5` |
| delay | number | **Optional** | Delay in ms before updating entity | `0` | | second_step | number | **Optional** | Second change when clicking arrows (only with `hide.seconds: false`) | `5` |
| layout | object | **Optional** | Card Layout configuration | `none` | | delay | number | **Optional** | Delay in ms before updating entity | `0` |
| hide | object | **Optional** | Hide object | `none` | | layout | object | **Optional** | Card Layout configuration | `none` |
| tap_action | action | **Optional** | Home assistant action to perform on tap | `more-info` | | hide | object | **Optional** | Hide object | `none` |
| double_tap_action | action | **Optional** | Home assistant action to perform on tap | `more-info` | | tap_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
| hold_action | action | **Optional** | Home assistant action to perform on tap | `more-info` | | double_tap_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
| hold_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
### Layout Object ### Layout Object
@@ -158,7 +159,7 @@ you can use the following variables in your theme's config file:
### Structured names ### Structured names
*Requires Home Assistant 2026.4 or later. On earlier versions a structured `name` falls back to the entity's friendly name.* _Requires Home Assistant 2026.4 or later. On earlier versions a structured `name` falls back to the entity's friendly name._
Home Assistant composes an entity's display name out of its registry context Home Assistant composes an entity's display name out of its registry context
(entity, device, area, floor) rather than one `friendly_name` string. `name` can (entity, device, area, floor) rather than one `friendly_name` string. `name` can
@@ -194,9 +195,9 @@ See the [Home Assistant developer documentation](https://developers.home-assista
[downloads-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/releases [downloads-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/releases
[releases-shield]: https://img.shields.io/github/release/GeorgeSG/lovelace-time-picker-card.svg [releases-shield]: https://img.shields.io/github/release/GeorgeSG/lovelace-time-picker-card.svg
[releases-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/releases [releases-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/releases
[ci-shield]: https://img.shields.io/github/workflow/status/GeorgeSG/lovelace-time-picker-card/CI?label=CI&logo=github& [ci-shield]: https://img.shields.io/github/actions/workflow/status/GeorgeSG/lovelace-time-picker-card/ci.yaml?branch=master&label=CI&logo=github
[ci-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/actions?query=workflow%3ACI [ci-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/actions?query=workflow%3ACI
[maintenance-shield]: https://img.shields.io/maintenance/yes/2022.svg [maintenance-shield]: https://img.shields.io/maintenance/yes/2026.svg
[maintenance-link]: https://github.com/GeorgeSG/lovelace-time-picker-card [maintenance-link]: https://github.com/GeorgeSG/lovelace-time-picker-card
[license-shield]: https://img.shields.io/github/license/GeorgeSG/lovelace-time-picker-card?color=brightgreen [license-shield]: https://img.shields.io/github/license/GeorgeSG/lovelace-time-picker-card?color=brightgreen
[license-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/blob/master/LICENSE [license-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/blob/master/LICENSE
+2 -8
View File
@@ -6,18 +6,12 @@ homeassistant:
logger: logger:
default: info default: info
logs:
hacs: debug
lovelace: lovelace:
mode: yaml resource_mode: yaml
resources: resources:
- type: module - type: module
url: /hacsfiles/button-card/button-card.js url: http://127.0.0.1:5100/time-picker-card.js
- type: module
url: /hacsfiles/lovelace-state-switch/state-switch.js
- type: module
url: http://127.0.0.1:5000/time-picker-card.js
dashboards: dashboards:
lovelace-home: lovelace-home:
mode: yaml mode: yaml
@@ -0,0 +1,12 @@
# Test entities for the Time Picker Card dev dashboard (ui-lovelace.yaml).
input_datetime:
test_date_time:
name: Test date time
has_date: true
has_time: true
initial: '2026-01-01 07:30:00'
test_date:
name: Test date (no time)
has_date: true
has_time: false
initial: '2026-01-01'
+8 -9
View File
@@ -6,7 +6,7 @@ views:
- type: vertical-stack - type: vertical-stack
cards: cards:
- type: markdown - type: markdown
content: "# 🕰 Time Picker Card dev" content: '# 🕰 Time Picker Card dev'
- type: entities - type: entities
entities: entities:
- input_datetime.test_date_time - input_datetime.test_date_time
@@ -21,20 +21,20 @@ views:
action: call-service action: call-service
service: logbook.log service: logbook.log
service_data: service_data:
name: "Time picker card · Tap Action" name: 'Time picker card · Tap Action'
message: "Debug" message: 'Debug'
double_tap_action: double_tap_action:
action: call-service action: call-service
service: logbook.log service: logbook.log
service_data: service_data:
name: "Time picker card · Double Tap Action" name: 'Time picker card · Double Tap Action'
message: "Debug" message: 'Debug'
hold_action: hold_action:
action: call-service action: call-service
service: logbook.log service: logbook.log
service_data: service_data:
name: "Time picker card · Hold action" name: 'Time picker card · Hold action'
message: "Debug" message: 'Debug'
- type: custom:time-picker-card - type: custom:time-picker-card
name: Hour mode 12, thin, single, hidden name name: Hour mode 12, thin, single, hidden name
entity: input_datetime.test_date_time entity: input_datetime.test_date_time
@@ -60,7 +60,7 @@ views:
thin: true thin: true
- type: custom:time-picker-card - type: custom:time-picker-card
entity: input_datetime.test_date_time entity: input_datetime.test_date_time
name: Hidden seconds name: Shown seconds
hide: hide:
seconds: false seconds: false
@@ -120,7 +120,6 @@ views:
cards: cards:
- type: vertical-stack - type: vertical-stack
cards: cards:
- type: entities - type: entities
entities: entities:
- input_datetime.test_date_time - input_datetime.test_date_time
+31
View File
@@ -0,0 +1,31 @@
import js from '@eslint/js';
import { defineConfig, globalIgnores } from 'eslint/config';
import prettier from 'eslint-config-prettier';
import lit from 'eslint-plugin-lit';
import wc from 'eslint-plugin-wc';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default defineConfig([
globalIgnores(['dist/', 'node_modules/', 'devtools/']),
js.configs.recommended,
tseslint.configs.recommended,
lit.configs['flat/recommended'],
wc.configs['flat/recommended'],
{
languageOptions: {
globals: globals.browser,
parserOptions: { experimentalDecorators: true },
},
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/explicit-function-return-type': ['warn', { allowExpressions: true }],
},
},
{
files: ['*.config.{js,ts}'],
languageOptions: { globals: globals.node },
},
prettier,
]);
-8
View File
@@ -1,8 +0,0 @@
module.exports = {
preset: 'ts-jest/presets/js-with-babel',
testRegex: '/test/.*\\.test?\\.ts$',
transformIgnorePatterns: [
'node_modules/(?!(testing-library__dom|@open-wc|lit-html|lit-element|pure-lit|lit-element-state-decoupler)/)',
],
moduleFileExtensions: ['ts', 'js'],
};
+2240 -4187
View File
File diff suppressed because it is too large Load Diff
+35 -23
View File
@@ -13,38 +13,50 @@
"lovelace-custom-card" "lovelace-custom-card"
], ],
"license": "MIT", "license": "MIT",
"type": "module",
"repository": "git@github.com:GeorgeSG/lovelace-time-picker-card.git", "repository": "git@github.com:GeorgeSG/lovelace-time-picker-card.git",
"engines": {
"node": ">=24",
"npm": ">=11"
},
"scripts": { "scripts": {
"start": "rollup -c --watch --bundleConfigAsCjs", "start": "rollup -c --watch",
"build": "npm run lint && npm run rollup", "build": "npm run lint && npm run typecheck && npm run rollup",
"lint": "eslint src/*.ts", "lint": "eslint .",
"rollup": "rollup -c --bundleConfigAsCjs", "rollup": "rollup -c",
"test": "jest", "test": "vitest run",
"release-patch": "npm version patch && git push origin master --tags", "release-patch": "npm version patch && git push origin master --tags",
"release-minor": "npm version minor && git push origin master --tags", "release-minor": "npm version minor && git push origin master --tags",
"release-major": "npm version major && git push origin master --tags", "release-major": "npm version major && git push origin master --tags",
"start:ha": "docker run --rm -p8124:8123 -v $(pwd)/devtools/homeassistant:/config homeassistant/home-assistant:latest" "start:ha": "docker run --rm -p8124:8123 -v $(pwd)/devtools/homeassistant:/config homeassistant/home-assistant:latest",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
"format:check": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"custom-card-helpers": "^1.9.0", "custom-card-helpers": "^2.0.0",
"home-assistant-js-websocket": "^8.0.0", "home-assistant-js-websocket": "^9.7.0",
"lit": "^2.8.0" "lit": "^3.3.3"
}, },
"devDependencies": { "devDependencies": {
"@el3um4s/rollup-plugin-terser": "^1.0.2", "@eslint/js": "^10.0.1",
"@rollup/plugin-babel": "^6.0.2",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-json": "^6.1.0", "@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-typescript": "^12.1.1", "@rollup/plugin-terser": "^1.0.0",
"@types/jest": "^29.2.1", "@rollup/plugin-typescript": "^12.3.0",
"@typescript-eslint/eslint-plugin": "^8.13.0", "eslint": "^10.10.0",
"eslint": "^8.26.0", "eslint-config-prettier": "^10.1.8",
"jest": "^29.2.2", "eslint-plugin-lit": "^2.3.1",
"prettier": "^3.3.3", "eslint-plugin-wc": "^3.1.0",
"rollup": "^4.24.4", "globals": "^17.12.0",
"rollup-plugin-serve": "^2.0.1", "happy-dom": "^20.14.5",
"ts-jest": "^29.0.3", "prettier": "^3.9.6",
"typescript": "^5.6.3" "rollup": "^4.63.3",
"rollup-plugin-serve": "^3.0.0",
"tslib": "^2.8.1",
"typescript": "~6.0.3",
"typescript-eslint": "^8.70.0",
"vitest": "^5.0.1"
} }
} }
+3 -7
View File
@@ -1,16 +1,15 @@
import nodeResolve from '@rollup/plugin-node-resolve'; import nodeResolve from '@rollup/plugin-node-resolve';
import json from '@rollup/plugin-json'; import json from '@rollup/plugin-json';
import typescript from '@rollup/plugin-typescript'; import typescript from '@rollup/plugin-typescript';
import { terser } from '@el3um4s/rollup-plugin-terser'; import terser from '@rollup/plugin-terser';
import babel from '@rollup/plugin-babel';
import serve from 'rollup-plugin-serve'; import serve from 'rollup-plugin-serve';
const dev = process.env.ROLLUP_WATCH; const dev = process.env.ROLLUP_WATCH;
const serveOptions = { const serveOptions = {
contentBase: ['./dist'], contentBase: ['./dist'],
host: 'localhost', host: '127.0.0.1', // 'localhost' resolves to ::1 on Node 17+, but HA loads the card from 127.0.0.1
port: 5000, port: 5100, // 5000 is taken by the macOS AirPlay Receiver
allowCrossOrigin: true, allowCrossOrigin: true,
headers: { headers: {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -27,9 +26,6 @@ export default {
typescript({ exclude: ['test/**/*'] }), typescript({ exclude: ['test/**/*'] }),
nodeResolve(), nodeResolve(),
json(), json(),
babel({
exclude: 'node_modules/**',
}),
dev && serve(serveOptions), dev && serve(serveOptions),
!dev && terser(), !dev && terser(),
], ],
+4 -5
View File
@@ -1,8 +1,7 @@
import { noChange } from 'lit'; import { noChange } from 'lit';
import { AttributePart, directive, Directive, DirectiveParameters } from 'lit/directive.js'; import { AttributePart, directive, Directive, DirectiveParameters } from 'lit/directive.js';
import { fireEvent } from 'custom-card-helpers'; import { ActionHandlerDetail, ActionHandlerOptions, fireEvent } from 'custom-card-helpers';
import { ActionHandlerDetail, ActionHandlerOptions } from 'custom-card-helpers/dist/types';
const isTouch = const isTouch =
'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.maxTouchPoints > 0; 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.maxTouchPoints > 0;
@@ -10,7 +9,7 @@ const isTouch =
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
interface ActionHandler extends HTMLElement { interface ActionHandler extends HTMLElement {
holdTime: number; holdTime: number;
bind(element: Element, options): void; bind(element: Element, options?: ActionHandlerOptions): void;
} }
interface ActionHandlerElement extends HTMLElement { interface ActionHandlerElement extends HTMLElement {
@@ -69,7 +68,7 @@ class ActionHandler extends HTMLElement implements ActionHandler {
); );
} }
public bind(element: ActionHandlerElement, options): void { public bind(element: ActionHandlerElement, options: ActionHandlerOptions = {}): void {
if (element.actionHandler) { if (element.actionHandler) {
return; return;
} }
@@ -201,7 +200,7 @@ export const actionHandler = directive(
return noChange; return noChange;
} }
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars, @typescript-eslint/explicit-function-return-type // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/explicit-function-return-type
render(_options?: ActionHandlerOptions) {} render(_options?: ActionHandlerOptions) {}
}, },
); );
+3 -3
View File
@@ -4,7 +4,7 @@ import { html, LitElement, TemplateResult } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { TimePickerCardConfig } from './types'; import { TimePickerCardConfig } from './types';
const NAME_TO_LABEL_MAP = { const NAME_TO_LABEL_MAP: Record<string, string> = {
entity: 'input_datetime entity id', entity: 'input_datetime entity id',
name: 'Name', name: 'Name',
hour_step: 'Hour step', hour_step: 'Hour step',
@@ -128,7 +128,7 @@ export class TimePickerCardEditor extends LitElement implements LovelaceCardEdit
@property({ type: Object }) hass!: HomeAssistant; @property({ type: Object }) hass!: HomeAssistant;
@property() private config!: TimePickerCardConfig; @property() private config!: TimePickerCardConfig;
private computeLabel({ name }): string { private computeLabel({ name }: { name: string }): string {
return NAME_TO_LABEL_MAP[name] || name; return NAME_TO_LABEL_MAP[name] || name;
} }
@@ -149,7 +149,7 @@ export class TimePickerCardEditor extends LitElement implements LovelaceCardEdit
`; `;
} }
setConfig(config): void { setConfig(config: TimePickerCardConfig): void {
this.config = config; this.config = config;
} }
+1 -1
View File
@@ -41,7 +41,7 @@ type HassWithEntityNames = HomeAssistant & {
export function computeEntityName( export function computeEntityName(
hass: HomeAssistant, hass: HomeAssistant,
stateObj: HassEntity | undefined, stateObj: HassEntity | undefined,
name: EntityName | undefined name: EntityName | undefined,
): string | undefined { ): string | undefined {
const configuredName = typeof name === 'string' ? name : undefined; const configuredName = typeof name === 'string' ? name : undefined;
+5 -1
View File
@@ -8,7 +8,11 @@ import { TimeUnit } from './time-unit';
export class Hour extends TimeUnit { export class Hour extends TimeUnit {
private static readonly VALUE_LIMIT = 24; private static readonly VALUE_LIMIT = 24;
constructor(value: number, step = DEFAULT_HOUR_STEP, private hourMode?: HourMode) { constructor(
value: number,
step = DEFAULT_HOUR_STEP,
private hourMode?: HourMode,
) {
super(value, step, Hour.VALUE_LIMIT); super(value, step, Hour.VALUE_LIMIT);
} }
+10 -2
View File
@@ -19,7 +19,11 @@ export abstract class TimeUnit {
* @param _step how much to increase / decrease the value when step-changing * @param _step how much to increase / decrease the value when step-changing
* @param _limit value upper limit * @param _limit value upper limit
*/ */
constructor(private _value: number, protected _step: number, protected _limit: number) {} constructor(
private _value: number,
protected _step: number,
protected _limit: number,
) {}
get value(): number { get value(): number {
return this._value; return this._value;
@@ -40,7 +44,11 @@ export abstract class TimeUnit {
* @param direction * @param direction
*/ */
stepUpdate(direction: Direction, step: number = this._step): void { stepUpdate(direction: Direction, step: number = this._step): void {
direction === Direction.UP ? this.increment(step) : this.decrement(step); if (direction === Direction.UP) {
this.increment(step);
} else {
this.decrement(step);
}
} }
toString(): string { toString(): string {
+1 -1
View File
@@ -8,7 +8,7 @@ export class Time {
public hour: Hour, public hour: Hour,
public minute: Minute, public minute: Minute,
public second: Second, public second: Second,
private _linkValues: boolean = false private _linkValues: boolean = false,
) {} ) {}
hourStep(direction: Direction): void { hourStep(direction: Direction): void {
+29 -23
View File
@@ -36,7 +36,7 @@ import { Layout, Period, TimePickerCardConfig, TimePickerHideConfig } from './ty
console.info( console.info(
`%c TIME-PICKER-CARD \n%c Version ${CARD_VERSION} `, `%c TIME-PICKER-CARD \n%c Version ${CARD_VERSION} `,
'color: orange; font-weight: bold; background: black', 'color: orange; font-weight: bold; background: black',
'color: white; font-weight: bold; background: dimgray' 'color: white; font-weight: bold; background: dimgray',
); );
window.customCards = window.customCards || []; window.customCards = window.customCards || [];
@@ -133,7 +133,7 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
private renderNestedName( private renderNestedName(
name: string, name: string,
entity: HassEntity, entity: HassEntity,
hide?: TimePickerHideConfig hide?: TimePickerHideConfig,
): TemplateResult { ): TemplateResult {
const icon = html`<state-badge const icon = html`<state-badge
class="entity-icon" class="entity-icon"
@@ -177,7 +177,7 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
if (!this.entity.attributes.has_time) { if (!this.entity.attributes.has_time) {
return Partial.error( return Partial.error(
`You must set an ${ENTITY_DOMAIN} entity that sets has_time: true`, `You must set an ${ENTITY_DOMAIN} entity that sets has_time: true`,
this.config this.config,
); );
} }
@@ -192,9 +192,11 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
<ha-card class=${classMap(this.haCardClass)}> <ha-card class=${classMap(this.haCardClass)}>
${this.hasNameInHeader ? this.renderHeaderName(this.name!) : ''} ${this.hasNameInHeader ? this.renderHeaderName(this.name!) : ''}
<div class=${classMap(this.rowClass)}> <div class=${classMap(this.rowClass)}>
${this.hasNameInside ${
? this.renderNestedName(this.name!, this.entity, this.config.hide) this.hasNameInside
: ''} ? this.renderNestedName(this.name!, this.entity, this.config.hide)
: ''
}
<div class=${classMap(this.contentClass)}> <div class=${classMap(this.contentClass)}>
<time-unit <time-unit
@@ -208,28 +210,32 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
@stepChange=${this.onMinuteStepChange} @stepChange=${this.onMinuteStepChange}
@update=${this.debouncedCallHassService} @update=${this.debouncedCallHassService}
></time-unit> ></time-unit>
${this.config.hide?.seconds === false ${
? html`<div class="time-separator">:</div> this.config.hide?.seconds === false
<time-unit ? html`<div class="time-separator">:</div>
.unit=${this.time.second} <time-unit
@stepChange=${this.onSecondStepChange} .unit=${this.time.second}
@update=${this.debouncedCallHassService} @stepChange=${this.onSecondStepChange}
></time-unit>` @update=${this.debouncedCallHassService}
: ''} ></time-unit>`
${this.shouldShowPeriod : ''
? html`<time-period }
.period=${this.period} ${
.mode=${this.config.layout?.hour_mode ?? DEFAULT_LAYOUT_HOUR_MODE} this.shouldShowPeriod
@toggle=${this.onPeriodToggle} ? html`<time-period
></time-period>` .period=${this.period}
: ''} .mode=${this.config.layout?.hour_mode ?? DEFAULT_LAYOUT_HOUR_MODE}
@toggle=${this.onPeriodToggle}
></time-period>`
: ''
}
</div> </div>
</div> </div>
</ha-card> </ha-card>
`; `;
} }
setConfig(config): void { setConfig(config: TimePickerCardConfig): void {
if (!config) { if (!config) {
throw new Error('Invalid configuration'); throw new Error('Invalid configuration');
} }
@@ -382,7 +388,7 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
static getStubConfig( static getStubConfig(
_: HomeAssistant, _: HomeAssistant,
entities: Array<string> entities: Array<string>,
): Omit<TimePickerCardConfig, 'type'> { ): Omit<TimePickerCardConfig, 'type'> {
const datetimeEntity = entities.find((entityId) => computeDomain(entityId) === ENTITY_DOMAIN); const datetimeEntity = entities.find((entityId) => computeDomain(entityId) === ENTITY_DOMAIN);
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect, vi } from 'vitest';
import { HomeAssistant } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
import { computeEntityName, supportsEntityNameSelector } from '../src/entity-name';
const entity: HassEntity = {
entity_id: 'input_datetime.alarm',
state: '07:30:00',
attributes: { friendly_name: 'Alarm' },
last_changed: '',
last_updated: '',
context: { id: '', user_id: null, parent_id: null },
};
const hassWithVersion = (
version: string | undefined,
extra: Record<string, unknown> = {},
): HomeAssistant =>
({ config: version ? { version } : undefined, ...extra }) as unknown as HomeAssistant;
describe('supportsEntityNameSelector', () => {
it('is false before Home Assistant 2025.11', () => {
expect(supportsEntityNameSelector(hassWithVersion('2025.10.3'))).toBe(false);
expect(supportsEntityNameSelector(hassWithVersion('2024.12.0'))).toBe(false);
});
it('is true from Home Assistant 2025.11', () => {
expect(supportsEntityNameSelector(hassWithVersion('2025.11.0'))).toBe(true);
expect(supportsEntityNameSelector(hassWithVersion('2026.1.0'))).toBe(true);
});
it('is false when the version is unknown', () => {
expect(supportsEntityNameSelector(hassWithVersion(undefined))).toBe(false);
});
});
describe('computeEntityName', () => {
it('returns the configured string when there is no entity', () => {
expect(computeEntityName(hassWithVersion('2026.9.0'), undefined, 'Wake up')).toEqual('Wake up');
expect(computeEntityName(hassWithVersion('2026.9.0'), undefined, undefined)).toBeUndefined();
expect(
computeEntityName(hassWithVersion('2026.9.0'), undefined, [{ type: 'area' }]),
).toBeUndefined();
});
it('delegates to hass.formatEntityName from Home Assistant 2026.4', () => {
const formatEntityName = vi.fn().mockReturnValue('Bedroom Alarm');
const hass = hassWithVersion('2026.4.0', { formatEntityName });
const name = [{ type: 'area' as const }, { type: 'entity' as const }];
expect(computeEntityName(hass, entity, name)).toEqual('Bedroom Alarm');
expect(formatEntityName).toHaveBeenCalledWith(entity, name);
});
it('passes an undefined name through to hass.formatEntityName', () => {
const formatEntityName = vi.fn().mockReturnValue('Alarm');
computeEntityName(hassWithVersion('2026.4.0', { formatEntityName }), entity, undefined);
expect(formatEntityName).toHaveBeenCalledWith(entity, undefined);
});
it('falls back to the friendly name before Home Assistant 2026.4', () => {
const formatEntityName = vi.fn();
const hass = hassWithVersion('2026.3.2', { formatEntityName });
expect(computeEntityName(hass, entity, [{ type: 'area' }])).toEqual('Alarm');
expect(computeEntityName(hass, entity, undefined)).toEqual('Alarm');
expect(formatEntityName).not.toHaveBeenCalled();
});
it('prefers a configured string over the friendly name on older versions', () => {
expect(computeEntityName(hassWithVersion('2026.3.2'), entity, 'Wake up')).toEqual('Wake up');
});
it('falls back when a recent hass does not carry formatEntityName', () => {
expect(computeEntityName(hassWithVersion('2026.9.0'), entity, 'Wake up')).toEqual('Wake up');
expect(computeEntityName(hassWithVersion('2026.9.0'), entity, undefined)).toEqual('Alarm');
});
});
+1
View File
@@ -1,3 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Hour } from '../../src/models/hour'; import { Hour } from '../../src/models/hour';
import { Direction } from '../../src/types'; import { Direction } from '../../src/types';
import { DEFAULT_HOUR_STEP } from '../../src/const'; import { DEFAULT_HOUR_STEP } from '../../src/const';
+1
View File
@@ -1,3 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Minute } from '../../src/models/minute'; import { Minute } from '../../src/models/minute';
import { Direction } from '../../src/types'; import { Direction } from '../../src/types';
+1
View File
@@ -1,3 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Second } from '../../src/models/second'; import { Second } from '../../src/models/second';
import { Direction } from '../../src/types'; import { Direction } from '../../src/types';
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import { Hour } from '../../src/models/hour';
import { Minute } from '../../src/models/minute';
import { Second } from '../../src/models/second';
import { Time } from '../../src/models/time';
import { Direction } from '../../src/types';
const time = (h: number, m: number, s: number, linkValues = false): Time =>
new Time(new Hour(h), new Minute(m, 5), new Second(s, 5), linkValues);
describe('Time', () => {
describe('value', () => {
it('joins the units with colons', () => {
expect(time(7, 5, 0).value).toEqual('7:5:0');
});
});
describe('hourStep', () => {
it('steps the hour without touching minutes', () => {
const t = time(23, 30, 0);
t.hourStep(Direction.UP);
expect([t.hour.value, t.minute.value]).toEqual([0, 30]);
});
});
describe('minuteStep', () => {
it('does not change the hour when values are not linked', () => {
const t = time(11, 55, 0);
t.minuteStep(Direction.UP);
expect([t.hour.value, t.minute.value]).toEqual([11, 0]);
});
it('carries an overflow into the next hour when values are linked', () => {
const t = time(11, 55, 0, true);
t.minuteStep(Direction.UP);
expect([t.hour.value, t.minute.value]).toEqual([12, 0]);
});
it('borrows from the previous hour when values are linked', () => {
const t = time(0, 0, 0, true);
t.minuteStep(Direction.DOWN);
expect([t.hour.value, t.minute.value]).toEqual([23, 55]);
});
it('carries by one hour regardless of the hour step', () => {
const t = new Time(new Hour(10, 3), new Minute(55, 5), new Second(0), true);
t.minuteStep(Direction.UP);
expect(t.hour.value).toEqual(11);
});
it('does not carry when the step stays within the hour', () => {
const t = time(10, 30, 0, true);
t.minuteStep(Direction.UP);
expect([t.hour.value, t.minute.value]).toEqual([10, 35]);
});
});
describe('secondStep', () => {
it('does not change the minute when values are not linked', () => {
const t = time(10, 30, 55);
t.secondStep(Direction.UP);
expect([t.minute.value, t.second.value]).toEqual([30, 0]);
});
it('carries an overflow into the next minute when values are linked', () => {
const t = time(10, 30, 55, true);
t.secondStep(Direction.UP);
expect([t.minute.value, t.second.value]).toEqual([31, 0]);
});
it('borrows from the previous minute when values are linked', () => {
const t = time(10, 30, 0, true);
t.secondStep(Direction.DOWN);
expect([t.minute.value, t.second.value]).toEqual([29, 55]);
});
});
});
+171
View File
@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { LitElement } from 'lit';
import { HomeAssistant } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
import '../src/time-picker-card';
import { TimePickerCard } from '../src/time-picker-card';
import { TimePickerCardConfig } from '../src/types';
// The card renders Home Assistant's error card for invalid configs; stub the
// element so the test can read the error it was given.
class ErrorCardStub extends HTMLElement {
config?: { error: string };
setConfig(config: { error: string }): void {
this.config = config;
}
}
customElements.define('hui-error-card', ErrorCardStub);
const ENTITY_ID = 'input_datetime.alarm';
/** YAML is untyped at runtime, so setConfig has to cope with shapes the type forbids. */
const invalidConfig = (config: Record<string, unknown>): TimePickerCardConfig =>
config as unknown as TimePickerCardConfig;
const alarm = (hour: number, minute = 30, second = 0): HassEntity => ({
entity_id: ENTITY_ID,
state: `${hour}:${minute}:${second}`,
attributes: { friendly_name: 'Alarm', has_time: true, has_date: false, hour, minute, second },
last_changed: '',
last_updated: '',
context: { id: '', user_id: null, parent_id: null },
});
const createHass = (entity: HassEntity = alarm(7)): HomeAssistant =>
({
states: { [entity.entity_id]: entity },
config: { version: '2026.9.2' },
callService: vi.fn().mockResolvedValue(undefined),
}) as unknown as HomeAssistant;
const renderCard = async (
config: Partial<TimePickerCardConfig>,
hass: HomeAssistant = createHass(),
): Promise<TimePickerCard> => {
const card = document.createElement('time-picker-card') as TimePickerCard;
card.setConfig({ type: 'custom:time-picker-card', entity: ENTITY_ID, ...config });
card.hass = hass;
document.body.appendChild(card);
await settle(card);
return card;
};
/** Waits for the card and its nested Lit children to finish rendering. */
const settle = async (card: TimePickerCard): Promise<void> => {
await card.updateComplete;
const children = card.shadowRoot!.querySelectorAll('time-unit, time-period');
await Promise.all(Array.from(children).map((el) => (el as LitElement).updateComplete));
};
const inputValues = (card: TimePickerCard): string[] =>
Array.from(card.shadowRoot!.querySelectorAll('time-unit')).map(
(unit) => unit.shadowRoot!.querySelector<HTMLInputElement>('input')!.value,
);
describe('time-picker-card', () => {
let card: TimePickerCard;
afterEach(() => {
card?.remove();
});
describe('setConfig', () => {
beforeEach(() => {
card = document.createElement('time-picker-card') as TimePickerCard;
});
it('rejects a missing entity', () => {
expect(() => card.setConfig(invalidConfig({ type: 'custom:time-picker-card' }))).toThrow(
'You must set an entity',
);
});
it('rejects an unknown hour mode', () => {
expect(() =>
card.setConfig(
invalidConfig({ type: 'custom:time-picker-card', entity: ENTITY_ID, hour_mode: 10 }),
),
).toThrow('Invalid hour_mode');
});
});
describe('rendering', () => {
it('shows hour and minute inputs with the entity time', async () => {
card = await renderCard({});
expect(inputValues(card)).toEqual(['07', '30']);
expect(card.shadowRoot!.querySelector('time-period')).toBeNull();
});
it('shows the entity name in the header by default', async () => {
card = await renderCard({});
expect(card.shadowRoot!.querySelector('.time-picker-header')!.textContent!.trim()).toEqual(
'Alarm',
);
});
it('shows seconds and an AM/PM toggle when configured', async () => {
card = await renderCard({ hour_mode: 12, hide: { seconds: false } });
expect(inputValues(card)).toEqual(['07', '30', '00']);
expect(card.shadowRoot!.querySelector('time-period')).not.toBeNull();
});
it('re-renders when hass delivers a new entity state', async () => {
card = await renderCard({});
card.hass = createHass(alarm(9, 45));
await settle(card);
expect(inputValues(card)).toEqual(['09', '45']);
});
it('renders an error card when the entity is missing', async () => {
card = await renderCard({ entity: 'input_datetime.missing' });
const error = card.shadowRoot!.querySelector('hui-error-card') as ErrorCardStub;
expect(error.config?.error).toEqual('Entity not found');
});
it('renders an error card when the entity has no time', async () => {
const dateOnly = { ...alarm(7), attributes: { friendly_name: 'Date', has_time: false } };
card = await renderCard({}, createHass(dateOnly));
const error = card.shadowRoot!.querySelector('hui-error-card') as ErrorCardStub;
expect(error.config?.error).toContain('has_time: true');
});
});
describe('interaction', () => {
it('calls set_datetime when an hour arrow is clicked', async () => {
const hass = createHass();
card = await renderCard({}, hass);
const hourUnit = card.shadowRoot!.querySelector('time-unit')!;
hourUnit.shadowRoot!.querySelector<HTMLElement>('.time-picker-icon')!.click();
expect(hass.callService).toHaveBeenCalledWith('input_datetime', 'set_datetime', {
entity_id: ENTITY_ID,
time: '8:30:0',
});
});
it('debounces the service call when a delay is configured', async () => {
vi.useFakeTimers();
try {
const hass = createHass();
card = await renderCard({ delay: 500 }, hass);
const [up] = Array.from(
card
.shadowRoot!.querySelector('time-unit')!
.shadowRoot!.querySelectorAll<HTMLElement>('.time-picker-icon'),
);
up.click();
up.click();
expect(hass.callService).not.toHaveBeenCalled();
vi.advanceTimersByTime(500);
expect(hass.callService).toHaveBeenCalledTimes(1);
expect(hass.callService).toHaveBeenCalledWith('input_datetime', 'set_datetime', {
entity_id: ENTITY_ID,
time: '9:30:0',
});
} finally {
vi.useRealTimers();
}
});
});
});
+15 -8
View File
@@ -1,18 +1,25 @@
{ {
// TypeScript stays on the 6.0 line: typescript-eslint declares a peer range of
// >=4.8.4 <6.1.0, and @rollup/plugin-typescript needs the JS compiler API
// that the native (7.x) compiler does not expose yet.
"compilerOptions": { "compilerOptions": {
"target": "es2017", "target": "ES2021",
"module": "esnext", "module": "ESNext",
"moduleResolution": "node", "moduleResolution": "bundler",
"lib": ["es2017", "dom", "dom.iterable"], "lib": ["ES2021", "DOM", "DOM.Iterable"],
"noEmit": true, "noEmit": true,
"strict": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"strict": true,
"noImplicitAny": false,
"skipLibCheck": true, "skipLibCheck": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true,
// Lit reactive properties rely on TypeScript's legacy decorators. Keep
// useDefineForClassFields off: it defaults to true for target >= ES2022 and
// then class fields silently shadow the accessors that @property() installs.
"experimentalDecorators": true, "experimentalDecorators": true,
"allowSyntheticDefaultImports": true // for importing sinon in tests "useDefineForClassFields": false
} },
"include": ["src", "test", "vitest.config.ts"]
} }
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
environment: 'happy-dom',
},
});