From 05e303d7712789ebca80cb37b676ff14b0df6918 Mon Sep 17 00:00:00 2001 From: Darren Griffin Date: Wed, 26 Mar 2025 14:45:36 +0000 Subject: [PATCH] Add simple clock card (#24599) * Initial clock card * Tidy clock card and change stub config * Change fallback to 'nothing' * Update src/panels/lovelace/cards/types.ts Co-authored-by: Paul Bottein * Update src/panels/lovelace/cards/hui-clock-card.ts Co-authored-by: Paul Bottein * Update src/panels/lovelace/cards/hui-clock-card.ts Co-authored-by: Paul Bottein * Added cardSize and gridOptions. Fixed invalid time type * Improve font sizes * Fix default case handling * Move interval outside class * WIP improvements * Various improvements * Improve date instantiation and display * Reintroduce localized time format * Swap to uusing key for time_format translation * Add fallback for initial load * Final fixes * Update clock card description * Update src/panels/lovelace/cards/types.ts Co-authored-by: Bram Kragten * Tidy up * Present css better * Change default sizing to small * Set default data * Change to grid, rework typography alignment * Update hui-clock-card.ts * Update hui-clock-card.ts --------- Co-authored-by: Paul Bottein Co-authored-by: Bram Kragten --- src/panels/lovelace/cards/hui-clock-card.ts | 261 ++++++++++++++++++ src/panels/lovelace/cards/types.ts | 8 + .../create-element/create-card-element.ts | 1 + .../config-elements/hui-clock-card-editor.ts | 145 ++++++++++ src/panels/lovelace/editor/lovelace-cards.ts | 4 + src/translations/en.json | 18 ++ 6 files changed, 437 insertions(+) create mode 100644 src/panels/lovelace/cards/hui-clock-card.ts create mode 100644 src/panels/lovelace/editor/config-elements/hui-clock-card-editor.ts diff --git a/src/panels/lovelace/cards/hui-clock-card.ts b/src/panels/lovelace/cards/hui-clock-card.ts new file mode 100644 index 0000000000..c99498614d --- /dev/null +++ b/src/panels/lovelace/cards/hui-clock-card.ts @@ -0,0 +1,261 @@ +import type { PropertyValues } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "../../../components/ha-alert"; +import "../../../components/ha-card"; +import type { HomeAssistant } from "../../../types"; +import type { + LovelaceCard, + LovelaceCardEditor, + LovelaceGridOptions, +} from "../types"; +import type { ClockCardConfig } from "./types"; +import { useAmPm } from "../../../common/datetime/use_am_pm"; +import { resolveTimeZone } from "../../../common/datetime/resolve-time-zone"; + +const INTERVAL = 1000; + +@customElement("hui-clock-card") +export class HuiClockCard extends LitElement implements LovelaceCard { + public static async getConfigElement(): Promise { + await import("../editor/config-elements/hui-clock-card-editor"); + return document.createElement("hui-clock-card-editor"); + } + + public static getStubConfig(): ClockCardConfig { + return { + type: "clock", + }; + } + + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: ClockCardConfig; + + @state() private _dateTimeFormat?: Intl.DateTimeFormat; + + @state() private _timeHour?: string; + + @state() private _timeMinute?: string; + + @state() private _timeSecond?: string; + + @state() private _timeAmPm?: string; + + private _tickInterval?: undefined | number; + + public setConfig(config: ClockCardConfig): void { + this._config = config; + this._initDate(); + } + + private _initDate() { + if (!this._config || !this.hass) { + return; + } + + let locale = this.hass?.locale; + + if (this._config?.time_format) { + locale = { ...locale, time_format: this._config.time_format }; + } + + this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: useAmPm(locale) ? "h12" : "h23", + timeZone: resolveTimeZone(locale.time_zone, this.hass.config?.time_zone), + }); + + this._tick(); + } + + public getCardSize(): number { + if (this._config?.clock_size === "small") return 1; + return 2; + } + + public getGridOptions(): LovelaceGridOptions { + if (this._config?.clock_size === "medium") { + return { + min_rows: 1, + rows: 2, + max_rows: 4, + min_columns: 4, + columns: 6, + }; + } + + if (this._config?.clock_size === "large") { + return { + min_rows: 2, + rows: 2, + max_rows: 4, + min_columns: 6, + columns: 6, + }; + } + + return { + min_rows: 1, + rows: 1, + max_rows: 4, + min_columns: 4, + columns: 6, + }; + } + + protected updated(changedProps: PropertyValues) { + if (changedProps.has("hass")) { + const oldHass = changedProps.get("hass"); + if (!oldHass || oldHass.locale !== this.hass?.locale) { + this._initDate(); + } + } + } + + public connectedCallback() { + super.connectedCallback(); + this._startTick(); + } + + public disconnectedCallback() { + super.disconnectedCallback(); + this._stopTick(); + } + + private _startTick() { + this._tickInterval = window.setInterval(() => this._tick(), INTERVAL); + this._tick(); + } + + private _stopTick() { + if (this._tickInterval) { + clearInterval(this._tickInterval); + this._tickInterval = undefined; + } + } + + private _tick() { + if (!this._dateTimeFormat) return; + + const parts = this._dateTimeFormat.formatToParts(); + + this._timeHour = parts.find((part) => part.type === "hour")?.value; + this._timeMinute = parts.find((part) => part.type === "minute")?.value; + this._timeSecond = this._config?.show_seconds + ? parts.find((part) => part.type === "second")?.value + : undefined; + this._timeAmPm = parts.find((part) => part.type === "dayPeriod")?.value; + } + + protected render() { + if (!this._config) return nothing; + + return html` + +
+
+
${this._timeHour}
+
${this._timeMinute}
+ ${this._timeSecond !== undefined + ? html`
${this._timeSecond}
` + : nothing} + ${this._timeAmPm !== undefined + ? html`
${this._timeAmPm}
` + : nothing} +
+
+
+ `; + } + + static styles = css` + ha-card { + height: 100%; + } + + .time-wrapper { + display: flex; + height: 100%; + align-items: center; + justify-content: center; + } + + .time-parts { + align-items: center; + display: grid; + grid-template-areas: + "hour minute second" + "hour minute am-pm"; + + font-size: 2rem; + font-weight: 500; + line-height: 0.8; + padding: 16px 0; + } + + .time-wrapper.size-medium .time-parts { + font-size: 3rem; + } + + .time-wrapper.size-large .time-parts { + font-size: 4rem; + } + + .time-wrapper.size-medium .time-parts .time-part.second, + .time-wrapper.size-medium .time-parts .time-part.am-pm { + font-size: 16px; + margin-left: 6px; + } + + .time-wrapper.size-large .time-parts .time-part.second, + .time-wrapper.size-large .time-parts .time-part.am-pm { + font-size: 24px; + margin-left: 8px; + } + + .time-parts .time-part.hour { + grid-area: hour; + } + + .time-parts .time-part.minute { + grid-area: minute; + } + + .time-parts .time-part.second { + grid-area: second; + line-height: 0.9; + opacity: 0.4; + } + + .time-parts .time-part.am-pm { + grid-area: am-pm; + line-height: 0.9; + opacity: 0.6; + } + + .time-parts .time-part.second, + .time-parts .time-part.am-pm { + font-size: 12px; + font-weight: 500; + margin-left: 4px; + } + + .time-parts .time-part.hour:after { + content: ":"; + margin: 0 2px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-clock-card": HuiClockCard; + } +} diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index d557c40921..5e7e25ed8d 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -22,6 +22,7 @@ import type { } from "../entity-rows/types"; import type { LovelaceHeaderFooterConfig } from "../header-footer/types"; import type { LovelaceHeadingBadgeConfig } from "../heading-badges/types"; +import type { TimeFormat } from "../../../data/translation"; export type AlarmPanelCardConfigState = | "arm_away" @@ -346,6 +347,13 @@ export interface MarkdownCardConfig extends LovelaceCardConfig { show_empty?: boolean; } +export interface ClockCardConfig extends LovelaceCardConfig { + type: "clock"; + clock_size?: "small" | "medium" | "large"; + show_seconds?: boolean | undefined; + time_format?: TimeFormat; +} + export interface MediaControlCardConfig extends LovelaceCardConfig { entity: string; theme?: string; diff --git a/src/panels/lovelace/create-element/create-card-element.ts b/src/panels/lovelace/create-element/create-card-element.ts index 64ee2e0c53..8a4920376f 100644 --- a/src/panels/lovelace/create-element/create-card-element.ts +++ b/src/panels/lovelace/create-element/create-card-element.ts @@ -76,6 +76,7 @@ const LAZY_LOAD_TYPES = { logbook: () => import("../cards/hui-logbook-card"), map: () => import("../cards/hui-map-card"), markdown: () => import("../cards/hui-markdown-card"), + clock: () => import("../cards/hui-clock-card"), "media-control": () => import("../cards/hui-media-control-card"), "picture-elements": () => import("../cards/hui-picture-elements-card"), "picture-entity": () => import("../cards/hui-picture-entity-card"), diff --git a/src/panels/lovelace/editor/config-elements/hui-clock-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-clock-card-editor.ts new file mode 100644 index 0000000000..cbfffc1784 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-clock-card-editor.ts @@ -0,0 +1,145 @@ +import { html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { + assert, + assign, + boolean, + enums, + literal, + object, + optional, + union, +} from "superstruct"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-form/ha-form"; +import type { + HaFormSchema, + SchemaUnion, +} from "../../../../components/ha-form/types"; +import type { HomeAssistant } from "../../../../types"; +import type { LocalizeFunc } from "../../../../common/translations/localize"; +import type { ClockCardConfig } from "../../cards/types"; +import type { LovelaceCardEditor } from "../../types"; +import { baseLovelaceCardConfig } from "../structs/base-card-struct"; +import { TimeFormat } from "../../../../data/translation"; + +const cardConfigStruct = assign( + baseLovelaceCardConfig, + object({ + clock_size: optional( + union([literal("small"), literal("medium"), literal("large")]) + ), + time_format: optional(enums(Object.values(TimeFormat))), + show_seconds: optional(boolean()), + }) +); + +@customElement("hui-clock-card-editor") +export class HuiClockCardEditor + extends LitElement + implements LovelaceCardEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: ClockCardConfig; + + private _schema = memoizeOne( + (localize: LocalizeFunc) => + [ + { + name: "clock_size", + selector: { + select: { + mode: "dropdown", + options: ["small", "medium", "large"].map((value) => ({ + value, + label: localize( + `ui.panel.lovelace.editor.card.clock.clock_sizes.${value}` + ), + })), + }, + }, + }, + { + name: "show_seconds", + selector: { + boolean: {}, + }, + }, + { + name: "time_format", + selector: { + select: { + mode: "dropdown", + options: Object.values(TimeFormat).map((value) => ({ + value, + label: localize( + `ui.panel.lovelace.editor.card.clock.time_formats.${value}` + ), + })), + }, + }, + }, + ] as const satisfies readonly HaFormSchema[] + ); + + private _data = memoizeOne((config) => ({ + clock_size: "small", + time_format: TimeFormat.language, + show_seconds: false, + ...config, + })); + + public setConfig(config: ClockCardConfig): void { + assert(config, cardConfigStruct); + this._config = config; + } + + protected render() { + if (!this.hass || !this._config) { + return nothing; + } + + return html` + + `; + } + + private _valueChanged(ev: CustomEvent): void { + fireEvent(this, "config-changed", { config: ev.detail.value }); + } + + private _computeLabelCallback = ( + schema: SchemaUnion> + ) => { + switch (schema.name) { + case "clock_size": + return this.hass!.localize( + `ui.panel.lovelace.editor.card.clock.clock_size` + ); + case "time_format": + return this.hass!.localize( + `ui.panel.lovelace.editor.card.clock.time_format` + ); + case "show_seconds": + return this.hass!.localize( + `ui.panel.lovelace.editor.card.clock.show_seconds` + ); + default: + return undefined; + } + }; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-clock-card-editor": HuiClockCardEditor; + } +} diff --git a/src/panels/lovelace/editor/lovelace-cards.ts b/src/panels/lovelace/editor/lovelace-cards.ts index 2f22e6e3c0..ed04beb7f6 100644 --- a/src/panels/lovelace/editor/lovelace-cards.ts +++ b/src/panels/lovelace/editor/lovelace-cards.ts @@ -13,6 +13,10 @@ export const coreCards: Card[] = [ type: "calendar", showElement: true, }, + { + type: "clock", + showElement: true, + }, { type: "entities", showElement: true, diff --git a/src/translations/en.json b/src/translations/en.json index d9bd14dafd..ebe92fa7a4 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -7150,6 +7150,24 @@ }, "description": "The Markdown card is used to render Markdown." }, + "clock": { + "name": "Clock", + "description": "The Clock card displays the current time using your desired size and format.", + "clock_size": "Clock size", + "clock_sizes": { + "small": "Small", + "medium": "Medium", + "large": "Large" + }, + "show_seconds": "Display seconds", + "time_format": "Time format", + "time_formats": { + "language": "[%key:ui::panel::profile::time_format::formats::language%]", + "system": "[%key:ui::panel::profile::time_format::formats::system%]", + "24": "[%key:ui::panel::profile::time_format::formats::24%]", + "12": "[%key:ui::panel::profile::time_format::formats::12%]" + } + }, "media-control": { "name": "Media control", "description": "The Media control card is used to display media player entities on an interface with easy to use controls."