Connecting to Habitica


Me, pretending to be my witchie avatar.

Lately, I’ve moved my “Task List” over to Habitica for a couple of reasons:

The idea that tasks completed on Monday, are unchecked and ready to do again on Tuesday is something I haven’t seen with any other task tracker, not to mention the drops of pets and blingy costumes.

One feature I wish it had was the recurring tasks that are longer than a week. I can have a task of “Get Ready to Teach my Class” appear every Tuesday, but I can’t have “Clean the Coffee Pot” re-show at the first of each month, nor “Schedule Teeth Scraping” every six months.

Since the Habitica web site is a single page app, they have a complete REST API for everything. This means it is straight-forward to write a program to compensate for many features. Since one of the members of my party was trying to level up on Ruby, I thought I’d write a Ruby script that could run daily as a cronjob.

Couple of rules:

Data Structures

Given the rules listed above, it doesn’t take much to create a list of recurring events. Each event, if represented by a map of specific fields, I can just pass them along.

For instance, a day of the month could be represented by an integer:

    { text: "Clean Coffee Pot", date: 1 }

Events that happen once a year should just have a parse-able date:

      { text: "Talk like a Pirate", date: "Sep 19" }

Events that happen multiple times a year could have multiple dates in a string:

    { text: "Replace Air Filter", date: '20-May|1-Oct',
      tags: ["299ee46d-025a-4dab-87e6-7a4562eaffe1"]}

Found out the hard way that the tags need to be specify by ID. We’ll want to have a way to get those by name.

My blood donations that occur every eight weeks need something more complicated, so perhaps we can store an expression. In Ruby that could be a simple lambda:

    { text: "Blood Donation",
      date: lambda {|d| d.cweek % 8 == 2 and d.tuesday?},
      priority: 1.5, attribute: 'con',
      checklist: [{'text': 'Drink a cup of tea'},
                  {'text': 'Drink another cup of tea'},
                  {'text': 'Fill out RapidPass'},
                  {'text': 'Do not forget lunch'}]}

A Medium difficulty is specified by the priority field of 1.5. To create a task with a list of sub-tasks, create a checklist section.

While I might want to eventually put these tasks in a separate file, for now, let’s just create a global variable, @cyclic_tasks:

    @cyclic_tasks = [
      # Each entry is a map ...
    ]

When to Create

A function that could take a value from our large selection of date specifications and return true if the event should be created for a given date:

    def task_due?(now, date)
      if date.is_a? String
        # Dates as string would be something like Jan-13
        dates = date.split(/ *\| */).collect { |d| Date.parse(d) }
        dates.any? { |d| now.yday == d.yday }
      elsif date.is_a? Integer
        now.mday == date
      elsif date.is_a? Proc
        date.call(now)
      else  # Assume `date` is an actual Date object
        now.month == date.month and now.mon == date.mon
      end
    end

Let’s make sure this works as expected:

    require 'date'

    now = Date.new(2017, 02, 05)

    task_due?(now, 5)
    task_due?(now, 'Feb-05')
    task_due?(now, 'Jan 10 | Feb 5 | Mar 23')
    task_due?(now, lambda {|d| d.yday == 36 })

Yeah, we should move this into a Spec test file.

What to Create

Loop through the recurring events data structure, @cyclic_tasks and give us a list of what tasks should be created:

    def todays(date, tasks)
      tasks.select do |task|
        task_due?(date, task[:date])
      end
    end

What dates will we want to generate recurring tasks?

    theFirst = Date.new(2017, 1, 1)
    365.times do |d|
      date = theFirst + d
      tasks = todays(date, @cyclic_tasks)
      tasks.each do |task|
         p "#{task[:text]} on #{date.asctime}"
      end
    end

That bit of code will verify the tasks you want to create. Now, about creating those todos…

Connecting to the Web Service

While people have created a nice Ruby library for Habitica, the API is a so straight-forward and simple, we can access easily with the standard HTTPClient library. Create a new client:

    require 'httpclient'

    @http = HTTPClient.new

And then, we can create a couple of helper functions for GET and POST calls: Note, these require your User ID and the API Key:

    @habitica_url = 'https://habitica.com/api/v3'

    def hab_get(path, query = nil)
      req = @http.get "#{habitica_url}/#{path}", query,
                      'Content-Type' => 'application/json',
                      'x-api-user' => @user_id, 'x-api-key' => @api_key
      JSON.parse(req.body)['data']
    end

    def hab_post(path, data = nil)
      @http.post "#{habitica_url}/#{path}", body=data,
                       'x-api-user' => @user_id, 'x-api-key' => @api_key
    end

Habitica-Specific Functions

Getting the list of tasks as an array of hash maps in trivial:

    def get_tasks
      hab_get('tasks/user')
    end

To filter the list to only the TODO type of tasks, we use select:

    def get_todos
      only_todos = lambda {|t| t['type'] == 'todo'}
      get_tasks.select(&only_todos)
    end

Since one of our rules was not to create a task if it already exists, we can use the any? function to check the list:

    def task_exists?(task)
      get_todos.any? { |t| t['text'] == task[:text]}
    end

Creating a task from one of the hashmap data structures above, is equally easy, as long as I remove the :date entry:

    def create_task(details)
      details.delete(:date)
      hab_post('tasks/user', details)
    end

To make sure what I create is a TODO, we just need to add the :type:

    def create_todo(details)
      details[:type] = 'todo'
      create_task(details)
    end

Final step is to create a main part where we iterate over our tasks, picking out anything that matches today that does not already exist:

    todays(Date.today, @cyclic_tasks).each do |task|
      create_todo(task) if not task_exists?(task)
    end

Done!

Slight improvement … creating a function to download my tags, and create an association of the tags name with its ID, allows me to write, tags()['morning']:

    def tags()
      tag_map = hab_get('tags').collect do |tag|
        [tag['name'], tag['id']]
      end
      Hash[tag_map]
    end

If I memoize that function in a global variable, @tags, then I can easily specify those tags in my original data structure.

Summary

This has been just a bit of exploration for connecting a simple Ruby script to your Habitica account. For me, I run this script daily, as part of a cron job every morning before I wake, so my todos are ready for me to complete.

Date: 2017 Jan 10

Created: 2020-12-23 Wed 17:28