Chore tracker dashboard

Hi guys,
I've been using HA for a few years, and now starting to get into more complex coding projects. I'm currently working on a chore tracker, and I'd be grateful for any feedback or tips you can give me.

(I have looked at add ons and HACS but nothing quite fit what I wanted, hence making it from scratch. This is loosely based on the Sweepy app, which I had previously been using but I want my own offline/free/tailored-to-me solution)

My issue: Currently card 2 is acting as one giant button, hence the workaround with browser_mod.

  • It works fine but it's a bit clunky
  • The browser_mod chores aren't dynamic and so aren't in the same order as they appear on card 2.
  • It also means I have to manually update the tap_action and the hold_action menus every time I add/remove a chore
  • I'm also thinking further down the line I would like to have card 1 in each room on my main dashboard, and clicking it would bring up card 2, so the less sequential popups the better.

I would like card 2 to be a dynamic list in due order (as it is currently) but each chore is its own button, where tap_action automatically updates the right input.datetime for that chore and hold_action automatically brings up the right input.datetime to edit the date. Would be really grateful for your thoughts on how to achieve that!

Also (minor detail though) if anyone can suggest how to remove the space between the last chore and the bottom of the card containing the chore list that would be fab.

Thanks!


Current setup (in a separate chore dashboard):

  • each room looks something like this. It's a vertical stack with card 1 [overview of the room cleanliness] and card 2 [all the chores specific to that room].

  • Card 1 changes colour depending on the state of chores in the room (due/overdue/ok/due soon), and says how many chores are due/overdue.

  • Card 2 is a dynamic list of all the chores in the room, ranked in due order. Colours change based on state (due/overdue/ok/due soon), and it says when it's next due / how long it's been overdue, and says when it was last done and how often it needs to be done.

  • a short press on Card 2 uses browser_mod to bring up the list of chores that I can click individually to say it's been done today

  • a long press on Card 2 uses browser_mod to bring up the chores' input.datetimes to manually adjust the date they were last done


Code

Code for card 1

type: custom:button-card
entity: sensor.groupe_salle_de_bain
name: Salle de bain
icon: mdi:shower
show_state: false
styles:
  card:
    - border-radius: 12px
    - padding: 12px 16px
    - margin-bottom: 4px
  grid:
    - grid-template-areas: "\"i n badge\""
    - grid-template-columns: 40px 1fr auto
  name:
    - font-size: 16px
    - font-weight: 500
    - justify-self: start
  icon:
    - width: 24px
    - color: var(--primary-text-color)
state:
  - value: overdue
    styles:
      card:
        - background: "#480C0C"
  - value: due
    styles:
      card:
        - background: "#4A2800"
  - value: due_soon
    styles:
      card:
        - background: "#3B3300"
  - value: ok
    styles:
      card:
        - background: "#1A3E29"
custom_fields:
  badge: |
    [[[
      if (!entity || !entity.attributes) return '';
      const overdue = entity.attributes.overdue_count;
      const due = entity.attributes.due_count;
      if (overdue > 0)
        return `<span style="background:rgba(0,0,0,0.2);color:var(--primary-text-color);border-radius:12px;padding:2px 8px;font-size:12px">${overdue} en retard</span>`;
      if (due > 0)
        return `<span style="background:rgba(0,0,0,0.2);color:var(--primary-text-color);border-radius:12px;padding:2px 8px;font-size:12px">${due} Ă  faire</span>`;
      return `<span style="background:rgba(0,0,0,0.2);color:var(--primary-text-color);border-radius:12px;padding:2px 8px;font-size:12px">tout ok</span>`;
    ]]]

Code for card 2

type: custom:button-card
entity: sensor.groupe_salle_de_bain
show_icon: false
show_name: false
show_state: false
tap_action:
  action: fire-dom-event
  browser_mod:
    service: browser_mod.popup
    data:
      title: Quelle(s) corvée(s) veux-tu mettre à jour?
      right_button: Fini
      right_button_appearance: filled
      content:
        type: vertical-stack
        cards:
          - type: custom:button-card
            color_type: blank-card
          - type: custom:button-card
            name: Nettoyer l'évier
            icon: mdi:faucet
            layout: icon_name
            aspect_ratio: 5/1
            tap_action:
              action: fire-dom-event
              browser_mod:
                service: script.chore_set_to_today
                data:
                  target_entity: input_datetime.chore_last_done_bathroom_clean_sink
          - type: custom:button-card
            name: Nettoyer la douche
            icon: mdi:shower
            layout: icon_name
            aspect_ratio: 5/1
            tap_action:
              action: fire-dom-event
              browser_mod:
                service: script.chore_set_to_today
                data:
                  target_entity: input_datetime.chore_last_done_bathroom_clean_shower
          - type: custom:button-card
            name: Nettoyer les toilettes
            icon: mdi:toilet
            layout: icon_name
            aspect_ratio: 5/1
            tap_action:
              action: fire-dom-event
              browser_mod:
                service: script.chore_set_to_today
                data:
                  target_entity: input_datetime.chore_last_done_bathroom_clean_toilet
          - type: custom:button-card
            name: Nettoyer la bonde de la douche
            icon: mdi:pipe
            layout: icon_name
            aspect_ratio: 5/1
            tap_action:
              action: fire-dom-event
              browser_mod:
                service: script.chore_set_to_today
                data:
                  target_entity: input_datetime.chore_last_done_bathroom_clean_shower_plug
          - type: custom:button-card
            name: Nettoyer la fenĂȘtre de la salle de bain
            icon: mdi:window-closed-variant
            layout: icon_name
            aspect_ratio: 5/1
            tap_action:
              action: fire-dom-event
              browser_mod:
                service: script.chore_set_to_today
                data:
                  target_entity: input_datetime.chore_last_done_bathroom_window
          - type: custom:button-card
            color_type: blank-card
hold_action:
  action: fire-dom-event
  browser_mod:
    service: browser_mod.popup
    data:
      title: Quelle(s) corvée(s) veux-tu mettre à jour?
      right_button: Fini
      right_button_appearance: filled
      content:
        type: entities
        entities:
          - entity: input_datetime.chore_last_done_bathroom_clean_sink
            name: Nettoyer l'évier
            icon: mdi:faucet
          - entity: input_datetime.chore_last_done_bathroom_clean_shower
            name: Nettoyer la douche
            icon: mdi:shower
          - entity: input_datetime.chore_last_done_bathroom_clean_toilet
            name: Nettoyer les toilettes
            icon: mdi:toilet
          - entity: input_datetime.chore_last_done_bathroom_clean_shower_plug
            name: Nettoyer la bonde de la douche
            icon: mdi:pipe
          - entity: input_datetime.chore_last_done_bathroom_window
            name: Nettoyer la fenĂȘtre de la salle de bain
            icon: mdi:window-closed-variant
styles:
  card:
    - padding: 0
    - background: none
    - box-shadow: none
  custom_fields:
    chore_list:
      - width: 100%
      - text-align: left
  grid:
    - grid-template-areas: "\"chore_list\""
    - grid-template-columns: 1fr
    - align-items: start
custom_fields:
  chore_list: |
    [[[
      if (!entity || !entity.attributes) return '';
      const chores = typeof entity.attributes.chores === 'string'
        ? JSON.parse(entity.attributes.chores)
        : entity.attributes.chores || [];

      const colours = {
        overdue:  { bg: '#480C0C', text: 'var(--primary-text-color)' },
        due:      { bg: '#4A2800', text: 'var(--primary-text-color)' },
        due_soon: { bg: '#3B3300', text: 'var(--primary-text-color)' },
        ok:       { bg: '#1A3E29', text: 'var(--primary-text-color)' }
      };

      const rows = chores.map(chore => {
        const c = colours[chore.state] || colours.ok;
        const formatDue = (days) => {
          if (days < 0) {
            const d = Math.abs(days);
            if (d >= 365) {
              const n = Math.floor(d / 365);
              return `${n} an${n > 1 ? 's' : ''} de retard`;
            } else if (d >= 30) {
              const n = Math.floor(d / 30);
              return `${n} mois de retard`;
            } else if (d >= 7) {
              const n = Math.floor(d / 7);
              return `${n} semaine${n > 1 ? 's' : ''} de retard`;
            } else {
              return `${d} jour${d > 1 ? 's' : ''} de retard`;
            }
          } else if (days === 0) {
            return `Ă  faire aujourd'hui`;
          } else {
            if (days >= 365) {
              const n = Math.floor(days / 365);
              return `dans ${n} an${n > 1 ? 's' : ''}`;
            } else if (days >= 30) {
              const n = Math.floor(days / 30);
              return `dans ${n} mois`;
            } else if (days >= 7) {
              const n = Math.floor(days / 7);
              return `dans ${n} semaine${n > 1 ? 's' : ''}`;
            } else {
              return `dans ${days} jour${days > 1 ? 's' : ''}`;
            }
          }
        };
        const dueLabel = formatDue(chore.days_until_due);
        const lastDone = chore.last_done && chore.last_done !== 'unknown'
          ? `fait le ${new Date(chore.last_done).toLocaleDateString('fr-FR', {day:'numeric', month:'long'})}`
          : 'jamais fait';
        
        const unitGender = (unitEnglish) => {
          if (unitEnglish === 'weeks') {
            return `toutes les`
          } else if (unitEnglish === 'days' || unitEnglish === 'months') {
            return `tous les`
          } else {
            return `bad unit`
          }
        };
        const unitGrammar = unitGender(chore.unit);
        const showFreqNumber = (freq) => {
          if (freq > 1) {
            return ` ${freq} `
          } else {
            return ` `
          }
        };
        const dueFreqNumber = showFreqNumber(chore.frequency);
        const unitFrench = (unitEnglish) => {
          if (unitEnglish === 'days') {
            return `jours`
          } else if (unitEnglish === 'weeks') {
            return `semaines`
          } else if (unitEnglish === 'months') {
            return `mois`
          } else {
            return `bad unit`
          }
        };
        const dueFreqUnit = unitFrench(chore.unit);
        const dueFrequency = `${unitGrammar}${dueFreqNumber}${dueFreqUnit}`;

        return `
          <div style="
            display: flex;
            align-items: center;
            justify-content: space-between;
            background: ${c.bg};
            border-radius: 8px;
            padding: 10px 14px;
            margin-bottom: 4px;
          ">
            <div style="display:flex;align-items:center;gap:8px">
              <ha-icon
                icon="${chore.icon}"
                style="color:${c.text};--mdc-icon-size:20px"
              ></ha-icon>
              <div style="display:flex;flex-direction:column;gap:2px">
                <span style="font-size:14px;font-weight:500;color:${c.text}">
                  ${chore.name}
                </span>
                <span style="font-size:11px;color:${c.text};opacity:0.8">
                  ${lastDone} - ${dueFrequency}
                </span>
              </div>
            </div>
            <span style="
              font-size:12px;
              font-weight:500;
              color:${c.text};
              background:rgba(0,0,0,0.2);
              border-radius:10px;
              padding:2px 8px;
            ">

              ${dueLabel}
            </span>
          </div>
        `;
      });

      return `<div style="padding:0 0 8px">${rows.join('')}</div>`;
    ]]]
1 Like