Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/add calendar card #1207

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 87 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Bubble Card is a minimalist and customizable card collection for Home Assistant

## Table of contents

**[`Installation`](#installation)** **[`Configuration`](#configuration)** **[`Pop-up`](#pop-up)** **[`Horizontal buttons stack`](#horizontal-buttons-stack)** **[`Button`](#button)** **[`Media player`](#media-player)** **[`Cover`](#cover)** **[`Select`](#select)** **[`Climate`](#climate)** **[`Separator`](#separator)** **[`Empty column`](#empty-column)** **[`Sub-buttons`](#sub-buttons)** **[`Card layouts`](#card-layouts)** **[`Actions`](#tap-double-tap-and-hold-actions)** **[`Styling`](#styling)** **[`Templates`](#templates)** **[`Conflicts`](#custom-components-conflicts)** **[`Help`](#help)** **[`Donate`](#donate)**
**[`Installation`](#installation)** **[`Configuration`](#configuration)** **[`Pop-up`](#pop-up)** **[`Horizontal buttons stack`](#horizontal-buttons-stack)** **[`Button`](#button)** **[`Media player`](#media-player)** **[`Cover`](#cover)** **[`Select`](#select)** **[`Climate`](#climate)** **[`Calendar`](#calendar)** **[`Separator`](#separator)** **[`Empty column`](#empty-column)** **[`Sub-buttons`](#sub-buttons)** **[`Card layouts`](#card-layouts)** **[`Actions`](#tap-double-tap-and-hold-actions)** **[`Styling`](#styling)** **[`Templates`](#templates)** **[`Conflicts`](#custom-components-conflicts)** **[`Help`](#help)** **[`Donate`](#donate)**

<br>

Expand Down Expand Up @@ -896,6 +896,92 @@ sub_button:

<br>

## Calendar

<!-- ![readme-climate-card](https://github.com/user-attachments/assets/59145c69-2f85-4ee7-a290-e848971e1925) -->

This card allows you to display your `calendar` entities.

### Calendar options

<details>

**<summary>Options (YAML + descriptions)</summary>**

| Name | Type | Requirement | Supported options | Description |
|---------------------|---------|--------------|-------------------------------------------------|-----------------------------------------------------------------------------------------|
| `entities` | object | **Required** | A calendar entity object (see below) | The entity to control (e.g., `calendar.main_calendar`). |
| `entities.entity` | string | **Required** | A calendar entity | The calendar entity to display |
| `entities.color` | string | Optional | A color | A custom color for the calendar chip. If not defined, an automatic color will be picked |
| `limit` | number | Optional | A number | The amont of events that will be displayed on the card |
| `show_end` | boolean | Optional | `true` or `false` (default) | Show or hide the end time for events |
| `show_progress` | boolean | Optional | `true` (default) or `false` | Show or hide the event progress bar |
| `tap_action` | object | Optional | See [actions](#tap-double-tap-and-hold-actions) | Define the action triggered on tap. If not defined, `more-info` will be used. |
| `double_tap_action` | object | Optional | See [actions](#tap-double-tap-and-hold-actions) | Define the action triggered on double tap. If not defined, `toggle` will be used. |
| `hold_action` | object | Optional | See [actions](#tap-double-tap-and-hold-actions) | Define the action triggered on hold. If not defined, `more-info` will be used. |
| `card_layout` | string | Optional | `normal` (default), `large`, `large-2-rows` | Defines the styling layout of the card. See [card layouts](#card-layouts). |
| `columns` | string | Optional | `1`, `2`, `3`, or `4` (default) | Number of columns when placed in a **section view**. |
| `rows` | string | Optional | `1` (default), `2`, `3`, or `4` | Number of rows when placed in a **section view**. |
| `sub_button` | object | Optional | See [sub-buttons](#sub-buttons) | Adds custom buttons fixed to the right. Useful for a climate mode select menu. |

</details>

<details>

**<summary>CSS variables (see [Styling](#styling))</summary>**

| Variable | Expected value | Description |
| ----------------------------------------- | -------------- | ------------------------------------------------------------------ |
| `--bubble-calendar-main-background-color` | `color` | Main background color for supported elements in the calendar card |
| `--bubble-calendar-border-radius` | `px` | Border radius for supported elements in the calendar card elements |
| `--bubble-calendar-height` | `px` | Height for the climate card |

</details>

#### Examples

<details>

<summary>A calendar card with a limited amount of events</summary>

<br>

```yaml
type: custom:bubble-card
card_type: calendar
entities:
- entity: calendar.main_calendar
color: '#ffb010'
limit: 1
```

</details>

<details>

<summary>A calendar card with an end time and a progress bar</summary>

<br>

```yaml
type: custom:bubble-card
card_type: calendar
entities:
- entity: calendar.main_calendar
color: '#ffb010'
show_end: true
show_progress: true
```

</details>

<br>

---

<br>


## Separator

![readme-separator](https://github.com/Clooos/Bubble-Card/assets/36499953/7e416a34-b95e-4a03-a200-4b3aa04f560d)
Expand Down
12 changes: 12 additions & 0 deletions src/bubble-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { handleButton } from './cards/button/index.js';
import { handleSeparator } from './cards/separator/index.js';
import { handleCover } from './cards/cover/index.js';
import { handleEmptyColumn } from './cards/empty-column/index.js';
import { handleCalendar } from './cards/calendar/index.js';
import { handleMediaPlayer } from './cards/media-player/index.js';
import { handleSelect } from './cards/select/index.js';
import { handleClimate } from './cards/climate/index.js';
Expand Down Expand Up @@ -95,6 +96,11 @@ class BubbleCard extends HTMLElement {
handleHorizontalButtonsStack(this);
break;

// Update calendar
case 'calendar' :
handleCalendar(this);
break;

// Update media player
case 'media-player' :
handleMediaPlayer(this);
Expand Down Expand Up @@ -145,6 +151,10 @@ class BubbleCard extends HTMLElement {
if (!config.entity && config.button_type !== 'name') {
throw new Error("You need to define an entity");
}
} else if (config.card_type === 'calendar') {
if (!config.entities) {
throw new Error("You need to define an entity list");
}
}

if (config.card_type === 'select' && config.entity && !config.select_attribute) {
Expand Down Expand Up @@ -191,6 +201,8 @@ class BubbleCard extends HTMLElement {
return 1;
case 'horizontal-buttons-stack':
return 0;
case 'calendar':
return 1;
case 'media-player':
return 1;
case 'select':
Expand Down
124 changes: 124 additions & 0 deletions src/cards/calendar/changes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import setupTranslation from "../../tools/localize.js";
import { addActions } from "../../tools/tap-actions.js";
import { hashCode, intToRGB } from "./helpers.js";

function dateDiffInMinutes(a, b) {
const MS_PER_MINUTES = 1000 * 60;

return Math.floor((b - a) / MS_PER_MINUTES);
}

export async function changeEventList(context) {
const daysOfEvents = context.config.days ?? 7;

const start = new Date().toISOString();
const end = new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * daysOfEvents).toISOString();
const params = `start=${start}&end=${end}`;

const promises = context.config.entities.map(async (entity) => {
const url = `calendars/${entity.entity}?${params}`;
const events = await context._hass.callApi("get", url);

return events.map(e => ({...e, entity}));
});

const events = await Promise.all(promises);

context.events = events.flat()
.sort(
(a, b) => new Date(a.start.date ?? a.start.dateTime).getTime() - new Date(b.start.date ?? b.start.dateTime).getTime()
)
.slice(0, context.config.limit ?? undefined);
}

export async function changeEvents(context) {
const t = setupTranslation(context._hass);
const eventsGroupedByDay = context.events.reduce((acc, event) => {
const day = event.start.date ?? event.start.dateTime.split('T')[0];
if (!acc[day]) {
acc[day] = [];
}
acc[day].push(event);
return acc;
}, {});

context.elements.calendarCardContent.innerHTML = '';

Object.keys(eventsGroupedByDay).sort().forEach((day) => {
const eventDay = new Date(day);
const today = new Date();
const dayNumber = document.createElement('div');
dayNumber.classList.add('bubble-day-number');
dayNumber.innerHTML = `${eventDay.getDate()}`;

const dayMonth = document.createElement('div');
dayMonth.classList.add('bubble-day-month');
dayMonth.innerHTML = eventDay.toLocaleString('default', { month: 'short' });

const dayChip = document.createElement('div');
dayChip.classList.add('bubble-day-chip');
dayChip.appendChild(dayNumber);
dayChip.appendChild(dayMonth);
if (eventDay.getDate() === today.getDate() && eventDay.getMonth() === today.getMonth()) {
dayChip.classList.add('is-active');
}

const dayEvents = document.createElement('div');
dayEvents.classList.add('bubble-day-events');

eventsGroupedByDay[day].forEach((event) => {
const eventTime = document.createElement('div');
eventTime.classList.add('bubble-event-time');
eventTime.innerHTML = event.start.date ? t("cards.calendar.all_day") : new Date(event.start.dateTime).toLocaleTimeString('default', { hour: 'numeric', minute: 'numeric' });
if (!event.start.date && context.config.show_end === true) {
eventTime.innerHTML += ` – ${new Date(event.end.dateTime).toLocaleTimeString('default', { hour: 'numeric', minute: 'numeric' })}`;
}

const eventName = document.createElement('div');
eventName.classList.add('bubble-event-name');
eventName.innerHTML = event.summary || t("cards.calendar.busy");

const eventColor = document.createElement('div');
eventColor.classList.add('bubble-event-color');
eventColor.style.backgroundColor = event.entity.color
? `var(--${event.entity.color}-color)`
: intToRGB(hashCode(event.entity.entity));

const eventLine = document.createElement('div');
eventLine.classList.add('bubble-event');
eventLine.appendChild(eventColor);
eventLine.appendChild(eventTime);
eventLine.appendChild(eventName);
addActions(eventLine, { ...context.config, entity: event.entity.entity });

const now = new Date();
const start = new Date(event.start.dateTime ?? event.start.date);
const end = new Date(event.end.dateTime ?? event.end.date);
const activeColor = 'var(--bubble-event-accent-color, var(--bubble-accent-color, var(--accent-color)))';

if (context.config.show_progress === true && event.start.date && start < now) {
eventLine.style.setProperty('--bubble-event-background-color', activeColor);
} else if (context.config.show_progress === true &&event.start.dateTime && start < now) {
const durationDiff = dateDiffInMinutes(start, end);
const startDiff = dateDiffInMinutes(start, now);
const percentage = 100 * startDiff / durationDiff;

eventLine.style.setProperty('--bubble-event-background-image', `linear-gradient(to right, ${activeColor} ${percentage}%, transparent ${percentage}%)`);
}

dayEvents.appendChild(eventLine);
});

const dayWrapper = document.createElement('div');
dayWrapper.classList.add('bubble-day-wrapper');
dayWrapper.appendChild(dayChip);
dayWrapper.appendChild(dayEvents);

context.elements.calendarCardContent.appendChild(dayWrapper);

if (context.elements.calendarCard.scrollHeight > context.elements.calendarCard.offsetHeight) {
context.elements.calendarCardWrapper.classList.add('is-overflowing');
}
});

}
32 changes: 32 additions & 0 deletions src/cards/calendar/create.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { createElement } from "../../tools/utils.js";
import styles from "./styles.css";

export function createStructure(context) {
context.elements = {};
context.elements.calendarCardContent = createElement('div', 'bubble-calendar-content');

context.elements.calendarCard = createElement('div', 'bubble-calendar');
context.elements.calendarCard.style.setProperty('--bubble-calendar-height', `${(context.config.rows ?? 1) * 56}px`);
context.elements.calendarCard.appendChild(context.elements.calendarCardContent);
context.elements.calendarCard.addEventListener('scroll', () => {
if (context.elements.calendarCard.scrollHeight > context.elements.calendarCard.offsetHeight + context.elements.calendarCard.scrollTop) {
context.elements.calendarCardWrapper.classList.add('is-overflowing');
} else {
context.elements.calendarCardWrapper.classList.remove('is-overflowing');
}
});

context.elements.style = createElement('style');
context.elements.style.innerText = styles;
context.elements.customStyle = createElement('style');

context.elements.calendarCardWrapper = createElement('div', 'bubble-calendar-wrapper');
context.elements.calendarCardWrapper.appendChild(context.elements.calendarCard);

context.content.innerHTML = '';
context.content.appendChild(context.elements.calendarCardWrapper);
context.content.appendChild(context.elements.style);
context.content.appendChild(context.elements.customStyle);

context.cardType = "calendar";
}
84 changes: 84 additions & 0 deletions src/cards/calendar/editor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { html } from "lit";
import setupTranslation from '../../tools/localize.js';
import "../../custom-elements/ha-selector-calendar_entity.js";

export function renderCalendarEditor(editor){
const t = setupTranslation(editor.hass);

return html`
<div class="card-config">
${editor.makeDropdown("Card type", "card_type", editor.cardTypeList)}
<ha-form
.hass=${editor.hass}
.data=${editor._config}
.schema=${[
{
name: "entities",
title: t('editor.calendar.entities'),
selector: { calendar_entity: {} },
},
]}
.computeLabel=${editor._computeLabelCallback}
@value-changed=${editor._valueChanged}
></ha-form>
<ha-expansion-panel outlined>
<h4 slot="header">
<ha-icon icon="mdi:cog"></ha-icon>
${t('editor.calendar.settings')}
</h4>
<div class="content">
<ha-form
.hass=${editor.hass}
.data=${editor._config}
.schema=${[
{
name: 'limit',
label: t('editor.calendar.limit'),
title: t('editor.calendar.limit'),
selector: { number: { step: 1, min: 1} },
},
{
name: 'show_end',
label: t('editor.calendar.show_end'),
title: t('editor.calendar.show_end'),
selector: { boolean: {} },
},
{
name: 'show_progress',
label: t('editor.calendar.show_progress'),
title: t('editor.calendar.show_progress'),
selector: { boolean: {} },
}
]}
.computeLabel=${editor._computeLabelCallback}
@value-changed=${editor._valueChanged}
></ha-form>
</div>
</ha-expansion-panel>
<ha-expansion-panel outlined>
<h4 slot="header">
<ha-icon icon="mdi:gesture-tap"></ha-icon>
Tap action on icon
</h4>
<div class="content">
${editor.makeActionPanel("Tap action")}
${editor.makeActionPanel("Double tap action")}
${editor.makeActionPanel("Hold action")}
</div>
</ha-expansion-panel>
<ha-expansion-panel outlined>
<h4 slot="header">
<ha-icon icon="mdi:palette"></ha-icon>
Styling options
</h4>
<div class="content">
${editor.makeLayoutOptions()}
${editor.makeStyleEditor()}
</div>
</ha-expansion-panel>
${editor.makeSubButtonPanel()}
<ha-alert alert-type="info">This card allows you to control a calendar. You can tap on the icon to get more control.</ha-alert>
${editor.makeVersion()}
</div>
`;
}
Loading