Custom component : Google Tasks (abandoned)

I haven’t tried using multiple lists yet sorry so I can’t help much. My first guess would have been to just add the integration again somehow but with a different default_list.

As far as losing authorization make sure you google oauth is ‘published’ and not still in testing. I just realized this way my issue.

The google documentation says, “A Google Cloud Platform project with an OAuth consent screen configured for an external user type and a publishing status of “Testing” is issued a refresh token expiring in 7 days.”

Actually I see that the original repo did have a way to add multi lists perhaps. See Custom component : Google Tasks (abandoned) - #42 by BlueBlueBlob
It looks like the fork or the repo that I found was from an older date so doesn’t include that work.

Anyone have a fork with the later work? I’ll see if I can find one.

I found a later fork and have applied all the additional work BlueBlueBlob had done.

Good news is this simplified the setup process and adds ability to use multiple task lists. Bad news is you have to setup everything again.

Setup is now all though the UI. Just save your credentials.json somewhere that can be read and copy the path. For the token path you can use any writable path ‘./custom_components/gtasks’ should work for most.

I have also updated the lovelace card a bit.

2 Likes

Amazing! Thank you.

Good looks on the unpublished as well, that was definitely it!

What are the chances of moving the Oauth2 from Desktop credentials to TV and Limited Input - this would remove the need to fish the code out of the URL, but would require rewriting the interface to poll for the token. I’m still reading through the home assistant helper code to try and make sense of it, there has been native Oauth2 support since the spring by the looks of things. The Google Calendars integration uses the device code authorization method and it is super painless.

Oh wow! This fork is way different - uses the gtasks_api rather than gtasks2. Looks promising!

EDIT: unfortunately I cannot get Google to authenticate for some reason…

Yes there are a decent number of changes. Overall its in a much better state than the previous one.

As far as moving from Desktop to TV/Limited Input or something like what Google Calendar does I agree that is the way to go. This method used to use the ‘out of bound’ option which was much nicer but was recently deprecated (Guia de migração de fluxos fora de banda (OOB)  |  Google Identity Platform  |  Google Developers) as a quick fix I just changed it to redirect to localhost. Unfortunately I think most of the Google OAuth stuff is done through gtasks_api which is also abandoned. I (or you would be welcome to) might see If I can resurrect that repo as well and then see what can be done.

1 Like

Oh… I forgot that the oob change was to gtasks_api… I will have to at least fork that temporarily. In the meantime you can edit the gtasks_api.py file (which you will need to find and location is dependent on your install). Edit line 45 so that redirect_uri=‘http://localhost/

1 Like

I’ve made a new version 0.5.1 which should ‘fix’ the issue for now. The gtasks_api is now incorporated as part of the repo and hopefully that will make it easier to also work on updating it in future.

1 Like

I was looking for it in the code but didn’t see it. I was able to change it in the url but then the code it sends back isn’t hashed right.

I’ll try the update! I definitely appreciate it. Will keep reading the code until it makes sense to me and then I’ll fork it and try to port it over to the TV type Oauth2 flow.

EDIT: That did the trick! thanks again!

Glad it worked.

I’m working on understanding the google auth parts better as well. So far I have determined that we can’t use the TV/Limited Device unfortunately google limits the scopes that you can do with that type of authorisation flow (OAuth 2.0 for TV and Limited-Input Device Applications  |  Google Identity  |  Google Developers). Google calendar isn’t listed as possible either but I tested it and it does work. So it seems it might be difficult unfortunately. Not sure what the next best option is now.

EDIT: further notes, Nest Desktop Auth Deprecation - Home Assistant shows a similar problem and it has been dealt with. I believe Application Credentials - Home Assistant might be the way to do it.

EDIT2: This looks good Application Credentials | Home Assistant Developer Docs

That is also the avenue I was looking into, although the documentation is written towards someone with a better handle on the framework of home assistant integration development.

I have upgraded everything today, including the HA Core and now I am having this:

I think I need to add googleapiclient to the manifest.json?

When I try to delete the GTasks integration it does nothing and shows this:


This error originated from a custom integration.

Logger: aiohttp.server
Source: custom_components/gtasks/gtasks_api.py:7
Integration: GTasks (documentation)
First occurred: 2:05:51 PM (1 occurrences)
Last logged: 2:05:51 PM

Error handling request
Traceback (most recent call last):
  File "/usr/local/lib/python3.10/site-packages/aiohttp/web_protocol.py", line 435, in _handle_request
    resp = await request_handler(request)
  File "/usr/local/lib/python3.10/site-packages/aiohttp/web_app.py", line 504, in _handle
    resp = await handler(request)
  File "/usr/local/lib/python3.10/site-packages/aiohttp/web_middlewares.py", line 117, in impl
    return await handler(request)
  File "/usr/src/homeassistant/homeassistant/components/http/security_filter.py", line 60, in security_filter_middleware
    return await handler(request)
  File "/usr/src/homeassistant/homeassistant/components/http/forwarded.py", line 100, in forwarded_middleware
    return await handler(request)
  File "/usr/src/homeassistant/homeassistant/components/http/request_context.py", line 28, in request_context_middleware
    return await handler(request)
  File "/usr/src/homeassistant/homeassistant/components/http/ban.py", line 82, in ban_middleware
    return await handler(request)
  File "/usr/src/homeassistant/homeassistant/components/http/auth.py", line 236, in auth_middleware
    return await handler(request)
  File "/usr/src/homeassistant/homeassistant/components/http/view.py", line 136, in handle
    result = await result
  File "/usr/src/homeassistant/homeassistant/components/config/config_entries.py", line 84, in delete
    result = await hass.config_entries.async_remove(entry_id)
  File "/usr/src/homeassistant/homeassistant/config_entries.py", line 911, in async_remove
    await entry.async_remove(self.hass)
  File "/usr/src/homeassistant/homeassistant/config_entries.py", line 532, in async_remove
    component = integration.get_component()
  File "/usr/src/homeassistant/homeassistant/loader.py", line 638, in get_component
    cache[self.domain] = importlib.import_module(self.pkg_path)
  File "/usr/local/lib/python3.10/importlib/__init__.py", line 126, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1050, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1027, in _find_and_load
  File "<frozen importlib._bootstrap>", line 1006, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 688, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 883, in exec_module
  File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
  File "/config/custom_components/gtasks/__init__.py", line 17, in <module>
    from .gtasks_api import GtasksAPI
  File "/config/custom_components/gtasks/gtasks_api.py", line 7, in <module>
    import googleapiclient._auth
ModuleNotFoundError: No module named 'googleapiclient'

SOLVED missing module by updating the manifest.json to this:
@myntath

{
  "domain": "gtasks",
  "name": "GTasks",
  "documentation": "https://github.com/myntath/gtasks-ha",
  "dependencies": [],
  "config_flow": true,
  "codeowners": [
    "@Myntath"
  ],
  "requirements": [
    "integrationhelper",
    "google-api-python-client==2.57.0"
  ],
  "version": "0.5.1"
}

I made some changes to the custom card script allowing for some more customization.

  • I added “show_check, show_add, and task_prefix” config variables. Adding “show_check: false” to the lovelace yaml config will hide the check marks. “show_add: false” will hide the add task part of the card. "task_prefix: " - " will add " - " before each task item. You can put whatever characters you want. I have a monitor on my wall with a home assistant dashboard, and displaying the checks and add_task was just wasting space since there are no input devices. The task_prefix helps readability for long winded task names.

  • I added classes for those respective divs also so you can customize those sections specifically with card-mod. Originally this was how I planned on hiding the above with a css “display: none” so I put a placeholder “display: flex” into the default css (which will get overridden by the parent anyway without !important)

EDIT: I also re-wrote the formatDueDate function so it returns MM-DD-YYYY rather than YYYY-MM-DD for future due dates. Was driving me nuts.

EDIT2: More css tweaks to get the formatting to look right to me. I indented the date and it tucks under the task nicely now, its a bit smaller so it reads as subscript now. Eventually I would like to add subtask functionality but for now this is pretty nice for me.

EDIT 3: Also, if you use multiple lists in a horizontal stack, they will now scale to 100% so they look even with each other. Still trying to figure out how to get the Add Task div to float at the bottom when this happens, without the tasks text overlapping it when they reach the bottom.

Feel free to push these to the repo if you want!

customElements.whenDefined('card-tools').then(() => {
  let cardTools = customElements.get('card-tools');
    
  class GtasksCard extends cardTools.LitElement {
    
    setConfig(config) {
      if (!config.entity) {
        throw new Error('Please define entity');
      }
      this.config = config;
    }
    
    calculateDueDate(dueDate){
      var oneDay = 24*60*60*1000; // hours*minutes*seconds*milliseconds
      var today = new Date();
      today.setHours(0,0,0,0)

      var splitDate = dueDate.split(/[- :T]/)
      var parsedDueDate = new Date(splitDate[0], splitDate[1]-1, splitDate[2]);
      parsedDueDate.setHours(0,0,0,0)
      
      var dueInDays;
      if(today > parsedDueDate) {
        dueInDays = -1;
      }
      else
        dueInDays = Math.round(Math.abs((today.getTime() - parsedDueDate.getTime())/(oneDay)));

      return dueInDays;
    }

    checkDueClass(dueInDays) {
      if (dueInDays == 0)
        return "due-today";
      else if (dueInDays < 0)
        return "overdue";
      else
        return "not-due";
    }

    formatDueDate(dueDate, dueInDays) {
      if (dueInDays < 0)
        return "Overdue";
      else if (dueInDays == 0)
        return "Today";
      else
        var splitDate = dueDate.split(/[- :T]/)
        return `${splitDate[1]}-${splitDate[2]}-${splitDate[0]}`;
    }

    render(){
      return cardTools.LitHtml
      `
        ${this._renderStyle()}
        ${cardTools.LitHtml
          `<ha-card>
            <h1 class="card-header">${this.header}</h1>
            <div>
              ${this.tasks.length > 0 ? cardTools.LitHtml`
              ${this.tasks.map((task, index) =>
                cardTools.LitHtml`
                <div class="info flex">
                  <div>
                    <div class="task-title">${this.task_prefix}${task.task_title}</div>
                    <div class="secondary">
                    <span class="${task.due_date != "-" ? this.checkDueClass(task.dueInDays) : ""}">${task.due_date != "-" ? "Due: " + this.formatDueDate(task.due_date, task.dueInDays) : ""}</span>
                    </div>
                  </div>
                  ${this.show_check != false ? cardTools.LitHtml`<div class="checkbox">
                  <mwc-button id=${'task_' + index} @click=${ev => this._complete(task.task_title, index)}>✓</mwc-button>
                </div>`
                  : ""}
                </div>

                `
              )}` : cardTools.LitHtml`<div class="info flex">- No tasks...</div>`}
            </div>
            ${this.notShowing.length > 0 ? cardTools.LitHtml`<div class="secondary">${"Look in Google Tasks for " + this.notShowing.length + " more tasks..."}</div>`
            : ""}
            ${this.show_add != false ? cardTools.LitHtml`
            <div class="info flex new-task">
            <div>
              <paper-input label="New Task" id="new_task_input" type="text" no-label-float>New Task</paper-input>
            </div>
            <div>
              <mwc-button id="new_task_button" @click=${ev => this._new_task()}>+</mwc-button>
            </div>
          </div>` : "" }
          </ha-card>`}
      `;
    }   
   
    async _complete(task_name, index){
      var sensor_name = 'sensor.gtasks_' + this.list_name.toLowerCase().replaceAll(' ', '_');
      this.shadowRoot.querySelector("#task_" + index).setAttribute('disabled');
      await this._hass.callService("gtasks", "complete_task", {
        task_title: task_name,
        tasks_list: this.list_name
      });
      await this._hass.callService("homeassistant", "update_entity", {
        entity_id: sensor_name 
      });
      this.shadowRoot.querySelector("#task_" + index).removeAttribute('disabled');
    }

    async _new_task(new_task_name){
      var new_task_name = this.shadowRoot.querySelector("#new_task_input").value;
      this.shadowRoot.querySelector("#new_task_input").setAttribute('disabled');
      this.shadowRoot.querySelector("#new_task_button").setAttribute('disabled');
      var sensor_name = 'sensor.gtasks_' + this.list_name.toLowerCase().replaceAll(' ', '_');
      await this._hass.callService("gtasks", "new_task", {
        task_title: new_task_name,
        tasks_list: this.list_name
      });
      await this._hass.callService("homeassistant", "update_entity", {
        entity_id: sensor_name 
      });
      this.shadowRoot.querySelector("#new_task_input").value = "";
      this.shadowRoot.querySelector("#new_task_input").removeAttribute('disabled');
      this.shadowRoot.querySelector("#new_task_button").removeAttribute('disabled');
    }
      
    _renderStyle() {
        return cardTools.LitHtml
        `
          <style>
            .card-header {
              padding: 0 0 10px !important; 
              margin-top: -10px;
              text-align: center;
              font-size: xx-large;
            }
            ha-card {
              padding: 16px;
              height: 100%;
            }
            .header {
              padding: 0;
              @apply --paper-font-headline;
              text-align: center;
              color: var(--primary-text-color);
              padding: 4px 0 12px;
            }
            .checkbox {
              display: flex;
            }
            .new-task {
              padding-top: 5px;
              line-height: normal !important;
            }
            .task-title {
              padding-left: 12px;
              text-indent: -12px;
              padding-top: 10px;
              padding-bottom: 10px;
            }
            .info {
              padding-bottom: 5px;
              font-size: large;
              align-items: center;
            }
            .flex {
              display: flex;
              justify-content: space-between;
            }
            .overdue {
              color: red !important;
            }
            .due-today {
              color: orange !important;
            }
            .secondary {
              display: block;
              font-size: 0.8em;
              color: #8c96a5;
              padding-left: 15px;
              margin-top: -4px;
          }
          </style>
        `;
      }
    
    set hass(hass) {
      this._hass = hass;
      
      const entity = hass.states[this.config.entity];
      const list_title = entity.attributes.friendly_name.split('_')[1]
      this.list_name = list_title
      this.header = this.config.title == null ? list_title : this.config.title;

      this.show_quantity = this.config.show_quantity == null ? null : this.config.show_quantity;
      this.show_days = this.config.show_days == null ? null : this.config.show_days;
      this.show_add = this.config.show_add == null ? null : this.config.show_add;
      this.show_check = this.config.show_check == null ? null : this.config.show_check;
      this.task_prefix = this.config.task_prefix == null ? null : this.config.task_prefix;

      if (entity.state == 'unknown')
        throw new Error("The Gtasks sensor is unknown.");
        
      var tasks = entity.attributes.tasks;
      var allTasks = []

      if(tasks != null){
        tasks.sort(function(a,b){
          if (a.due_date != null && b.due_date != null) {
            var aSplitDate = a.due_date.split(/[- :T]/)
            var bSplitDate = b.due_date.split(/[- :T]/)
  
            var aParsedDueDate = new Date(aSplitDate[0], aSplitDate[1]-1, aSplitDate[2]);
            var bParsedDueDate = new Date(bSplitDate[0], bSplitDate[1]-1, bSplitDate[2]);
  
            return bParsedDueDate - aParsedDueDate;
          }
            return;
        })

        tasks.map(task =>{
          var dueInDays = task.due_date ? this.calculateDueDate(task.due_date) : 10000;
          task.dueInDays = dueInDays;
          if(this.show_days != null) {
            if(dueInDays <= this.show_days){
              allTasks.unshift(task);
            }
            else if(task.due_date != null && task.due_date.slice(0,4) == "2999") {
              task.due_date = "-";
              allTasks.push(task)
            }
          }
          else {
            if(task.due_date == null || task.due_date == "-" || dueInDays == 10000 || task.due_date.slice(0,4) == "2999"){
              task.due_date = "-";
              allTasks.push(task)
            }
            else
              allTasks.unshift(task);
          }
        })
        
        if(this.show_quantity != null){
          this.tasks = allTasks.slice(0, this.show_quantity);
          this.notShowing = allTasks.slice(this.show_quantity);
        }
        else{
          this.tasks = allTasks;
          this.notShowing = 0;
        }
      }
      else
        this.tasks = allTasks;
      
      this.state = entity.state
      this.requestUpdate();
    }
    

  
      // @TODO: This requires more intelligent logic
    getCardSize() {
      return 3;
    }
  }
  
  customElements.define('gtasks-card', GtasksCard);
  });
  
  window.setTimeout(() => {
    if(customElements.get('card-tools')) return;
    customElements.define('gtasks-card', class extends HTMLElement{
      setConfig() { throw new Error("Can't find card-tools. See https://github.com/thomasloven/lovelace-card-tools");}
    });
  }, 2000);

That sounds great. Except as an Australian the date thing would kill me so that will either need to stay as it was or be a config option.

I would love to merge the rest of it. Do you have a repo I can pick the changes from?

Ahhh, I can make it a config item quick. I do not have a repo, I would probably just copy paste for now.

Here you go @myntath!

Same as above with the added 'date_format: “new” ’ config making the date in the DD-MM-YYYY format. Leaving the line out of the yaml defaults to the style you’re comfortable with.

Here is with everything enabled, and the new style date but with mushroom card titles and no title in this card:
full_todo

Here is the one from my smart calendar with the interactive stuff stripped out of the tasks cards but using the built in header title (shows off the 100% height change as well):

I also tweaked more of the css stuff. Labeled more divs with unique classes for tuning things better. I also adjusted the spacing of elements and made it scale better to removing the items I made removable in the config options. Should be pretty solid but if you find anything is broken let me know and I will address it!

customElements.whenDefined('card-tools').then(() => {
  let cardTools = customElements.get('card-tools');
    
  class GtasksCard extends cardTools.LitElement {
    
    setConfig(config) {
      if (!config.entity) {
        throw new Error('Please define entity');
      }
      this.config = config;
    }
    
    calculateDueDate(dueDate){
      var oneDay = 24*60*60*1000; // hours*minutes*seconds*milliseconds
      var today = new Date();
      today.setHours(0,0,0,0)

      var splitDate = dueDate.split(/[- :T]/)
      var parsedDueDate = new Date(splitDate[0], splitDate[1]-1, splitDate[2]);
      parsedDueDate.setHours(0,0,0,0)
      
      var dueInDays;
      if(today > parsedDueDate) {
        dueInDays = -1;
      }
      else
        dueInDays = Math.round(Math.abs((today.getTime() - parsedDueDate.getTime())/(oneDay)));

      return dueInDays;
    }

    checkDueClass(dueInDays) {
      if (dueInDays == 0)
        return "due-today";
      else if (dueInDays < 0)
        return "overdue";
      else
        return "not-due";
    }

    formatDueDate(dueDate, dueInDays) {
      if (dueInDays < 0)
        return "Overdue";
      else if (dueInDays == 0)
        return "Today";
      else if ( this.date_format == "new" )
      {
        var splitDate = dueDate.split(/[- :T]/)
        return `${splitDate[1]}-${splitDate[2]}-${splitDate[0]}`;
      }
      else
        return dueDate;
    }

    render(){
      return cardTools.LitHtml
      `
        ${this._renderStyle()}
        ${cardTools.LitHtml
          `<ha-card>
            <h1 class="card-header">${this.header}</h1>
            <div>
              ${this.tasks.length > 0 ? cardTools.LitHtml`
              ${this.tasks.map((task, index) =>
                cardTools.LitHtml`
                <div class="info flex task">
                  <div>
                    <div class="task-title">${this.task_prefix}${task.task_title}</div>
                    <div class="secondary">
                    <span class="${task.due_date != "-" ? this.checkDueClass(task.dueInDays) : ""}">${task.due_date != "-" ? "Due: " + this.formatDueDate(task.due_date, task.dueInDays) : ""}</span>
                    </div>
                  </div>
                  ${this.show_check != false ? cardTools.LitHtml`<div class="checkbox">
                  <mwc-button id=${'task_' + index} @click=${ev => this._complete(task.task_title, index)}>✓</mwc-button>
                </div>`
                  : ""}
                </div>

                `
              )}` : cardTools.LitHtml`<div class="info flex">- No tasks...</div>`}
            </div>
            ${this.notShowing.length > 0 ? cardTools.LitHtml`<div class="secondary">${"Look in Google Tasks for " + this.notShowing.length + " more tasks..."}</div>`
            : ""}
            ${this.show_add != false ? cardTools.LitHtml`
            <div class="info flex new-task">
            <div>
              <paper-input label="New Task" id="new_task_input" type="text" no-label-float>New Task</paper-input>
            </div>
            <div>
              <mwc-button id="new_task_button" @click=${ev => this._new_task()}>+</mwc-button>
            </div>
          </div>` : "" }
          </ha-card>`}
      `;
    }   
   
    async _complete(task_name, index){
      var sensor_name = 'sensor.gtasks_' + this.list_name.toLowerCase().replaceAll(' ', '_');
      this.shadowRoot.querySelector("#task_" + index).setAttribute('disabled');
      await this._hass.callService("gtasks", "complete_task", {
        task_title: task_name,
        tasks_list: this.list_name
      });
      await this._hass.callService("homeassistant", "update_entity", {
        entity_id: sensor_name 
      });
      this.shadowRoot.querySelector("#task_" + index).removeAttribute('disabled');
    }

    async _new_task(new_task_name){
      var new_task_name = this.shadowRoot.querySelector("#new_task_input").value;
      this.shadowRoot.querySelector("#new_task_input").setAttribute('disabled');
      this.shadowRoot.querySelector("#new_task_button").setAttribute('disabled');
      var sensor_name = 'sensor.gtasks_' + this.list_name.toLowerCase().replaceAll(' ', '_');
      await this._hass.callService("gtasks", "new_task", {
        task_title: new_task_name,
        tasks_list: this.list_name
      });
      await this._hass.callService("homeassistant", "update_entity", {
        entity_id: sensor_name 
      });
      this.shadowRoot.querySelector("#new_task_input").value = "";
      this.shadowRoot.querySelector("#new_task_input").removeAttribute('disabled');
      this.shadowRoot.querySelector("#new_task_button").removeAttribute('disabled');
    }
      
    _renderStyle() {
        return cardTools.LitHtml
        `
          <style>
            .card-header {
              padding: 0 0 10px !important; 
              margin-top: -10px;
              text-align: center;
              font-size: xx-large;
            }
            ha-card {
              padding: 16px;
              height: 100%;
            }
            .header {
              padding: 0;
              @apply --paper-font-headline;
              text-align: center;
              color: var(--primary-text-color);
              padding: 4px 0 12px;
            }
            .checkbox {
              display: flex;
            }

            .task {
              height: 40px;
              padding: 3px 0 3px 10px;
            }

            .new-task {
              padding-top: 5px;
              line-height: normal !important;
              padding-left: 15px;
            }

            .task-title {
              padding-left: 12px;
              text-indent: -12px;
            }
            .info {
              font-size: large;
              align-items: center;
            }
            .flex {
              display: flex;
              justify-content: space-between;
            }
            .overdue {
              color: red !important;
            }
            .due-today {
              color: orange !important;
            }
            .secondary {
              display: block;
              font-size: 0.8em;
              color: #8c96a5;
              padding-left: 15px;
              margin-top: -4px;
          }
          </style>
        `;
      }
    
    set hass(hass) {
      this._hass = hass;
      
      const entity = hass.states[this.config.entity];
      const list_title = entity.attributes.friendly_name.split('_')[1]
      this.list_name = list_title
      this.header = this.config.title == null ? list_title : this.config.title;

      this.show_quantity = this.config.show_quantity == null ? null : this.config.show_quantity;
      this.show_days = this.config.show_days == null ? null : this.config.show_days;
      this.show_add = this.config.show_add == null ? null : this.config.show_add;
      this.show_check = this.config.show_check == null ? null : this.config.show_check;
      this.task_prefix = this.config.task_prefix == null ? null : this.config.task_prefix;
      this.date_format = this.config.date_format == null ? null : this.config.date_format;

      if (entity.state == 'unknown')
        throw new Error("The Gtasks sensor is unknown.");
        
      var tasks = entity.attributes.tasks;
      var allTasks = []

      if(tasks != null){
        tasks.sort(function(a,b){
          if (a.due_date != null && b.due_date != null) {
            var aSplitDate = a.due_date.split(/[- :T]/)
            var bSplitDate = b.due_date.split(/[- :T]/)
  
            var aParsedDueDate = new Date(aSplitDate[0], aSplitDate[1]-1, aSplitDate[2]);
            var bParsedDueDate = new Date(bSplitDate[0], bSplitDate[1]-1, bSplitDate[2]);
  
            return bParsedDueDate - aParsedDueDate;
          }
            return;
        })

        tasks.map(task =>{
          var dueInDays = task.due_date ? this.calculateDueDate(task.due_date) : 10000;
          task.dueInDays = dueInDays;
          if(this.show_days != null) {
            if(dueInDays <= this.show_days){
              allTasks.unshift(task);
            }
            else if(task.due_date != null && task.due_date.slice(0,4) == "2999") {
              task.due_date = "-";
              allTasks.push(task)
            }
          }
          else {
            if(task.due_date == null || task.due_date == "-" || dueInDays == 10000 || task.due_date.slice(0,4) == "2999"){
              task.due_date = "-";
              allTasks.push(task)
            }
            else
              allTasks.unshift(task);
          }
        })
        
        if(this.show_quantity != null){
          this.tasks = allTasks.slice(0, this.show_quantity);
          this.notShowing = allTasks.slice(this.show_quantity);
        }
        else{
          this.tasks = allTasks;
          this.notShowing = 0;
        }
      }
      else
        this.tasks = allTasks;
      
      this.state = entity.state
      this.requestUpdate();
    }
    

  
      // @TODO: This requires more intelligent logic
    getCardSize() {
      return 3;
    }
  }
  
  customElements.define('gtasks-card', GtasksCard);
  });
  
  window.setTimeout(() => {
    if(customElements.get('card-tools')) return;
    customElements.define('gtasks-card', class extends HTMLElement{
      setConfig() { throw new Error("Can't find card-tools. See https://github.com/thomasloven/lovelace-card-tools");}
    });
  }, 2000);

Awesome thanks again. I have adopted most of those changes. I will make a new release soon. So if there is anything else you are thinking of let me know and that can be included.

1 Like

I am pretty content with what is there for now. Anything else I would want needs modification to the integration, and I am not quite there yet with that.

Mainly:

  • TV style Oauth2 authorization rather than the hack ( but functional ) solution there now.
  • Support for subtasks ( at least down to the 1st subtask level )

What you have going now with the tweaks I’ve made to the card component is exactly what I wanted all along. I am forever grateful for the help, this is a game changer for me (being able to focus on set tasks on my list). Thanks again!

Just checked out the changes you pushed, I dig how you handled the date format - looking good!

I dig the css changes too, although now the checkboxes are floating toward the bottom. Would like to find a way to have them centered on the line without setting a constant height for the div like I had. I only have my iPad right now so I can’t enter dev mode on chrome, but I will look into it when I have some time at home. Probably simple.

Did some more css work, I think these changes should keep everything fairly cohesive. Shortened the overflow tasks message, made it a separate class so that I could scale the dates properly. Line spacing is nice now, exposed it as a config value also (although card-mod would work for this too). Added auto cap and auto complete to the new task input. Check boxes land in the center of the line of text vertically. Heading spacing feels better, and has a min-height value for when its blank.

Probably the final update from my end without adding sub-tasks to the integration and card.

customElements.whenDefined("card-tools").then(() => {
  let cardTools = customElements.get("card-tools");

  class GtasksCard extends cardTools.LitElement {

    setConfig(config) {
      if (!config.entity) {
        throw new Error("Please define entity");
      }
      this.config = config;
    }

    calculateDueDate(dueDate){
      var oneDay = 24*60*60*1000; // hours*minutes*seconds*milliseconds
      var today = new Date();
      today.setHours(0,0,0,0);

      var splitDate = dueDate.split(/[- :T]/);
      var parsedDueDate = new Date(splitDate[0], splitDate[1]-1, splitDate[2]);
      parsedDueDate.setHours(0,0,0,0);

      var dueInDays;
      if(today > parsedDueDate) {
        dueInDays = -1;
      }
      else
        dueInDays = Math.round(Math.abs((today.getTime() - parsedDueDate.getTime())/(oneDay)));

      return dueInDays;
    }

    checkDueClass(dueInDays) {
      if (dueInDays == 0)
        return "due-today";
      else if (dueInDays < 0)
        return "overdue";
      else
        return "not-due";
    }

    formatDueDate(dueDate, dueInDays, dateFormat) {
      if (dueInDays < 0)
        return "Overdue";
      else if (dueInDays == 0)
        return "Today";
      else {
        if (dateFormat == "MDY") {
          var splitDate = dueDate.split(/[- :T]/)
          return `${splitDate[1]}-${splitDate[2]}-${splitDate[0]}`;
        } else if (dateFormat == "DMY") {
          var splitDate = dueDate.split(/[- :T]/)
          return `${splitDate[2]}-${splitDate[1]}-${splitDate[0]}`;
        } else {
          return dueDate.substr(0, 10);
        }
      }
    }

    render(){
      return cardTools.LitHtml
      `
        ${this._renderStyle()}
        ${cardTools.LitHtml
          `<ha-card>
            <h1 class="card-header">${this.header}</h1>
            <div>
              ${this.tasks.length > 0 ? cardTools.LitHtml`
              ${this.tasks.map((task, index) =>
                cardTools.LitHtml`
                <div class="info flex task">
                  <div>
                    <div class="task-title">${this.task_prefix}${task.task_title}</div>
                    <div class="date">
                    <span class="${task.due_date != "-" ? this.checkDueClass(task.dueInDays) : ""}">${task.due_date != "-" ? "Due: " + this.formatDueDate(task.due_date, task.dueInDays, this.date_format): ""}</span>
                    </div>
                  </div>
                  ${this.show_check != false ? cardTools.LitHtml`<div class="checkbox">
                  <mwc-button class="button" id=${"task_" + index} @click=${ev => this._complete(task.task_title, index)}>✓</mwc-button>
                </div>`
                  : ""}
                </div>

                `
              )}` : cardTools.LitHtml`<div class="info flex">- No tasks...</div>`}
            </div>
            ${this.notShowing.length > 0 ? cardTools.LitHtml`<div class="secondary">${"(" + this.notShowing.length + " more task" + (this.notShowing.length > 1 ? "s" : "") + "...)"}</div>`
            : ""}
            ${this.show_add != false ? cardTools.LitHtml`
            <div class="info flex new-task">
            <div>
              <paper-input label="New Task" id="new_task_input" type="text" no-label-float autocorrect="on" autocapitalize="all">New Task</paper-input>
            </div>
            <div>
              <mwc-button id="new_task_button" @click=${ev => this._new_task()}>+</mwc-button>
            </div>
          </div>` : "" }
          </ha-card>`}
      `;
    }

    async _complete(task_name, index){
      var sensor_name = "sensor.gtasks_" + this.list_name.toLowerCase().replaceAll(" ", "_");
      this.shadowRoot.querySelector("#task_" + index).setAttribute("disabled");
      await this._hass.callService("gtasks", "complete_task", {
        task_title: task_name,
        tasks_list: this.list_name
      });
      await this._hass.callService("homeassistant", "update_entity", {
        entity_id: sensor_name
      });
      this.shadowRoot.querySelector("#task_" + index).removeAttribute("disabled");
    }

    async _new_task(new_task_name){
      var new_task_name = this.shadowRoot.querySelector("#new_task_input").value;
      this.shadowRoot.querySelector("#new_task_input").setAttribute("disabled");
      this.shadowRoot.querySelector("#new_task_button").setAttribute("disabled");
      var sensor_name = "sensor.gtasks_" + this.list_name.toLowerCase().replaceAll(" ", "_");
      await this._hass.callService("gtasks", "new_task", {
        task_title: new_task_name,
        tasks_list: this.list_name
      });
      await this._hass.callService("homeassistant", "update_entity", {
        entity_id: sensor_name
      });
      this.shadowRoot.querySelector("#new_task_input").value = "";
      this.shadowRoot.querySelector("#new_task_input").removeAttribute("disabled");
      this.shadowRoot.querySelector("#new_task_button").removeAttribute("disabled");
    }

    _renderStyle() {
        return cardTools.LitHtml
        `
          <style>
            .card-header {
              text-align: center;
              padding: 0 0 8px;
              margin-top: -14px;
              min-height: 12px;
            }
            ha-card {
              padding: 16px;
              height: 100%;
            }
            .header {
              padding: 0;
              @apply --paper-font-headline;
              color: var(--primary-text-color);
              padding: 4px 0 12px;
            }
            .checkbox {
              display: flex;
            }
            .task {
              padding: 3px 0 3px 10px;
            }
            .new-task {
              padding-top: 5px;
              line-height: normal !important;
              padding-left: 15px;
            }
            .task-title {
              padding-left: 15px;
              text-indent: -15px;
              line-height: 1em;
            }
            .info {
              padding-bottom: 10px;
              min-height: ${this.min_height};
              font-size: 1.2em;
              align-items: center;
            }
            .flex {
              display: flex;
              justify-content: space-between;
            }
            .overdue {
              color: red !important;
            }
            .due-today {
              color: orange !important;
            }
            .secondary {
              display: block;
              font-size: 1em;
              color: #8c96a5;
              padding-left: 18px;
            }
            .date {
              display: block;
              font-size: 0.8em;
              color: #8c96a5;
              padding-left: 15px;
              margin-top: -4px;
              max-height: 15px;
            }
            .button {
              height: 0px;
              padding-top: 5px;
              align-items: center;
            }

          </style>
        `;
      }

    set hass(hass) {
      this._hass = hass;

      const entity = hass.states[this.config.entity];
      const list_title = entity.attributes.friendly_name.split("_")[1]
      this.list_name = list_title
      this.header = this.config.title == null ? list_title : this.config.title;

      this.show_quantity = this.config.show_quantity == null ? null : this.config.show_quantity;
      this.show_days = this.config.show_days == null ? null : this.config.show_days;
      this.show_add = this.config.show_add == null ? null : this.config.show_add;
      this.show_check = this.config.show_check == null ? null : this.config.show_check;
      this.task_prefix = this.config.task_prefix == null ? null : this.config.task_prefix;
      this.min_height = this.config.min_height == null ? "18px" : this.config.min_height;
      //options for date_format are "YMD" "DMY" "MDY"
      this.date_format = this.config.date_format == null ? "YMD" : this.config.date_format;

      if (entity.state == "unknown")
        throw new Error("The Gtasks sensor is unknown.");

      var tasks = entity.attributes.tasks;
      var allTasks = []

      if(tasks != null){
        tasks.sort(function(a,b){
          if (a.due_date != null && b.due_date != null) {
            var aSplitDate = a.due_date.split(/[- :T]/)
            var bSplitDate = b.due_date.split(/[- :T]/)

            var aParsedDueDate = new Date(aSplitDate[0], aSplitDate[1]-1, aSplitDate[2]);
            var bParsedDueDate = new Date(bSplitDate[0], bSplitDate[1]-1, bSplitDate[2]);

            return bParsedDueDate - aParsedDueDate;
          }
            return;
        })

        tasks.map(task =>{
          var dueInDays = task.due_date ? this.calculateDueDate(task.due_date) : 10000;
          task.dueInDays = dueInDays;
          if(this.show_days != null) {
            if(dueInDays <= this.show_days){
              allTasks.unshift(task);
            }
            else if(task.due_date != null && task.due_date.slice(0,4) == "2999") {
              task.due_date = "-";
              allTasks.push(task)
            }
          }
          else {
            if(task.due_date == null || task.due_date == "-" || dueInDays == 10000 || task.due_date.slice(0,4) == "2999"){
              task.due_date = "-";
              allTasks.push(task)
            }
            else
              allTasks.unshift(task);
          }
        })

        if(this.show_quantity != null){
          this.tasks = allTasks.slice(0, this.show_quantity);
          this.notShowing = allTasks.slice(this.show_quantity);
        }
        else{
          this.tasks = allTasks;
          this.notShowing = 0;
        }
      }
      else
        this.tasks = allTasks;

      this.state = entity.state
      this.requestUpdate();
    }



      // @TODO: This requires more intelligent logic
    getCardSize() {
      return 3;
    }
  }

  customElements.define("gtasks-card", GtasksCard);
  });

  window.setTimeout(() => {
    if(customElements.get("card-tools")) return;
    customElements.define("gtasks-card", class extends HTMLElement{
      setConfig() { throw new Error("Can't find card-tools. See https://github.com/thomasloven/lovelace-card-tools");}
    });
  }, 2000);