Grid-based scheduling on dashboard?

My household needs a way for unsophisticated users to quickly and easily change on/off schedules. Think things like lights, smart plugs etc. Either on a daily or weekly basis.

I’ve used simple hourly grid-based scheduling before in many programs and it’s super intuitive. You just toggle each hour by clicking on it. It can be made even quicker and easier by allowing the user to click and drag to toggle multiple stretches at once.

There’s no pop-ups, no typing in times, no dragging sliders. You just spray where you want something to be on/off and it fills in automatically.

I have tried several hours of searching and several hours of questioning various LLMs and still can’t find this basic feature even with any HACS repos. I can’t find anything close to this basic functionality.

How can I allow my users to set a schedule easily and intuitively without having to go into detailed settings pages and type things in?

I think schedule card integration comes closest to your wish, but far from grid based control.
For that, i kind of would start looking into custom card creation if you have some idea of dealing with javascript of dev agent (choose any you fancy).

Some initial idea, it’s all possible these days, but require a little more effort

i was little curious and went a litte more ahead (not 100% succes yet)
store this in www/grid-schedule-card.js

import { LitElement, html, css } from "https://unpkg.com/[email protected]/lit-element.js?module";

class GridScheduleCard extends LitElement {
  static get properties() {
    return {
      hass: { type: Object },
      config: { type: Object },
      _localSchedule: { type: Object }
    };
  }

  constructor() {
    super();
    this._localSchedule = null;
  }

  setConfig(config) {
    if (!config.entity) {
      throw new Error("Definieer een schedule entiteit in de YAML");
    }
    this.config = {
      slot_minutes: 60,
      color_on: '#4CAF50',
      color_off: '#F44336',
      title: 'Verwarming Schema',
      ...config
    };
  }

  shouldUpdate(changedProps) {
    if (changedProps.has('hass') && this.hass && this.config.entity) {
      const entityState = this.hass.states[this.config.entity];
      // Als er nog geen lokale staat is, synchroniseer met HA state
      if (entityState && !this._localSchedule) {
        this._localSchedule = JSON.parse(JSON.stringify(entityState.attributes || {}));
      }
    }
    return true;
  }

  static get styles() {
    return css`
      ha-card {
        padding: 16px;
      }
      .header-row {
        display: flex;
        margin-left: 45px;
        margin-bottom: 6px;
        font-size: 10px;
        color: var(--secondary-text-color, #8e8e93);
      }
      .header-slot {
        text-align: center;
      }
      .day-row {
        display: flex;
        align-items: center;
        margin-bottom: 5px;
      }
      .day-label {
        width: 45px;
        font-weight: bold;
        font-size: 12px;
      }
      .grid-container {
        display: flex;
        flex-grow: 1;
        gap: 2px;
      }
      .slot {
        flex: 1;
        height: 28px;
        border-radius: 3px;
        cursor: pointer;
        user-select: none;
        transition: background-color 0.15s ease, opacity 0.1s;
      }
      .slot:hover {
        opacity: 0.75;
      }
    `;
  }

  render() {
    if (!this.hass || !this.config) return html``;

    const days = ['Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za', 'Zo'];
    const dayKeys = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
    const totalSlots = Math.floor((24 * 60) / this.config.slot_minutes);
    const slotsPerHour = 60 / this.config.slot_minutes;

    // Gebruik lokale state voor directe visuele feedback, fallback op HA state
    const entityState = this.hass.states[this.config.entity];
    const scheduleData = this._localSchedule || (entityState ? entityState.attributes : {}) || {};

    return html`
      <ha-card .header="${this.config.title}">
        <div class="header-row">
          ${Array.from({ length: 24 }).map((_, hour) => html`
            <div class="header-slot" style="flex: ${slotsPerHour}">${hour}h</div>
          `)}
        </div>

        ${dayKeys.map((dayKey, dayIdx) => html`
          <div class="day-row">
            <div class="day-label">${days[dayIdx]}</div>
            <div class="grid-container">
              ${Array.from({ length: totalSlots }).map((_, slotIdx) => {
                const isActive = this._isSlotActive(scheduleData[dayKey], slotIdx);
                return html`
                  <div 
                    class="slot" 
                    style="background-color: ${isActive ? this.config.color_on : this.config.color_off}"
                    @click="${(e) => this._handleSlotClick(e, dayKey, slotIdx, isActive)}"
                    title="${this._getSlotTitle(slotIdx)}"
                  ></div>
                `;
              })}
            </div>
          </div>
        `)}
      </ha-card>
    `;
  }

  _isSlotActive(dayRanges, slotIdx) {
    if (!dayRanges || !Array.isArray(dayRanges)) return false;

    const slotStart = slotIdx * this.config.slot_minutes;
    const slotEnd = slotStart + this.config.slot_minutes;

    return dayRanges.some(range => {
      const rangeStart = this._timeToMinutes(range.from);
      const rangeEnd = this._timeToMinutes(range.to);
      return slotStart < rangeEnd && slotEnd > rangeStart;
    });
  }

  async _handleSlotClick(event, dayKey, slotIdx, currentState) {
    event.stopPropagation();
    
    const totalSlots = Math.floor((24 * 60) / this.config.slot_minutes);
    const entityState = this.hass.states[this.config.entity];
    const scheduleAttrs = this._localSchedule || (entityState ? entityState.attributes : {}) || {};

    // 1. Verkrijg de huidige staat van alle slots op de geklikte dag
    const slotStates = Array.from({ length: totalSlots }).map((_, idx) => 
      this._isSlotActive(scheduleAttrs[dayKey], idx)
    );

    // 2. Toggle status van het specifieke slot
    slotStates[slotIdx] = !currentState;

    // 3. Omzetten naar aaneengesloten intervallen (from / to)
    const newDayRanges = [];
    let inRange = false;
    let startMin = 0;

    for (let i = 0; i < totalSlots; i++) {
      if (slotStates[i] && !inRange) {
        inRange = true;
        startMin = i * this.config.slot_minutes;
      } else if (!slotStates[i] && inRange) {
        inRange = false;
        newDayRanges.push({
          from: this._minutesToTime(startMin),
          to: this._minutesToTime(i * this.config.slot_minutes)
        });
      }
    }

    if (inRange) {
      newDayRanges.push({
        from: this._minutesToTime(startMin),
        to: "24:00"
      });
    }

    // 4. Samenstellen van het volledige payload object
    const dayKeys = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
    const payloadDays = {};

    dayKeys.forEach(k => {
      const ranges = (k === dayKey) ? newDayRanges : (scheduleAttrs[k] || []);
      payloadDays[k] = ranges.map(r => ({
        from: r.from.substring(0, 5),
        to: r.to === "24:00:00" ? "24:00" : r.to.substring(0, 5)
      }));
    });

    // Direct de lokale staat bijwerken voor directe visuele kleuraanpassing op het scherm
    this._localSchedule = {
      ...scheduleAttrs,
      ...payloadDays
    };
    this.requestUpdate();

    const cleanScheduleId = this.config.entity.replace(/^schedule\./, '');
    const scheduleName = scheduleAttrs.friendly_name || (entityState ? entityState.attributes.friendly_name : cleanScheduleId);

    try {
      await this.hass.callWS({
        type: "schedule/update",
        schedule_id: cleanScheduleId,
        name: scheduleName,
        ...payloadDays
      });
      console.log("[GridScheduleCard] WS update succesvol verwerkt!");
    } catch (err) {
      console.error("[GridScheduleCard] Fout bij opslaan in WS:", err);
    }
  }

  _timeToMinutes(timeStr) {
    if (!timeStr) return 0;
    const parts = timeStr.split(':').map(Number);
    return parts[0] * 60 + parts[1];
  }

  _minutesToTime(minutes) {
    const h = String(Math.floor(minutes / 60)).padStart(2, '0');
    const m = String(minutes % 60).padStart(2, '0');
    return `${h}:${m}`;
  }

  _getSlotTitle(slotIdx) {
    const start = this._minutesToTime(slotIdx * this.config.slot_minutes);
    const end = this._minutesToTime((slotIdx + 1) * this.config.slot_minutes);
    return `${start} - ${end}`;
  }
}

customElements.define('grid-schedule-card', GridScheduleCard);

add dashboard resource
/local/grid-schedule-card.js?v=2.2

add a card on you dash:

type: custom:grid-schedule-card
entity: schedule.kerst
title: Verwarming Schema
slot_minutes: 60
color_on: '#4CAF50'
color_off: '#F44336'