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 <paul.bottein@gmail.com>

* Update src/panels/lovelace/cards/hui-clock-card.ts

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

* Update src/panels/lovelace/cards/hui-clock-card.ts

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

* 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 <mail@bramkragten.nl>

* 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 <paul.bottein@gmail.com>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
Darren Griffin 2025-03-26 14:45:36 +00:00 committed by GitHub
parent 53bb8251fa
commit 05e303d771
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 437 additions and 0 deletions

View File

@ -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<LovelaceCardEditor> {
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`
<ha-card>
<div
class="time-wrapper ${this._config.clock_size
? `size-${this._config.clock_size}`
: ""}"
>
<div class="time-parts">
<div class="time-part hour">${this._timeHour}</div>
<div class="time-part minute">${this._timeMinute}</div>
${this._timeSecond !== undefined
? html`<div class="time-part second">${this._timeSecond}</div>`
: nothing}
${this._timeAmPm !== undefined
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
: nothing}
</div>
</div>
</ha-card>
`;
}
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;
}
}

View File

@ -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;

View File

@ -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"),

View File

@ -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`
<ha-form
.hass=${this.hass}
.data=${this._data(this._config)}
.schema=${this._schema(this.hass.localize)}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
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;
}
}

View File

@ -13,6 +13,10 @@ export const coreCards: Card[] = [
type: "calendar",
showElement: true,
},
{
type: "clock",
showElement: true,
},
{
type: "entities",
showElement: true,

View File

@ -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."