Beeminder Forum

Arbitrary break scheduling script

#1

As mentioned here, I’ve switched to using my contract time goals directly in generating date-of-completion estimates for my clients. Because of this, it’s become much more important that my roads reflect a reasonable approximation of future reality.

As it stands, the current weekends-off feature only schedules a break for one upcoming weekend, which means that the road taken as a whole isn’t as accurate as it should be.

Also, weekends-off only allows you to schedule breaks for Saturdays and Sundays, and no other combination of weekdays.

So, I’ve written a script. It takes a goal’s slug and any combination of weekday abbreviations. It then schedules breaks all the way to the end of the goal for the weekdays provided. It does not remove previously-scheduled future breaks, and it leaves the road up to the end of the akrasia horizon unmodified.

Day abbreviations are case-insensitive and should otherwise match Python’s %a date format directive.

days_off currently defaults to Friday and Saturday, but modifying the code to change this behavior would be trivial.

Usage:

Install yaml and requests Python libraries, then:

chmod +x add-breaks.py
./add-breaks.py my-goal fri sat

Files:

config.yaml

auth:
  beeminder:
    username: ...
    token: ...

add-breaks.py

#!/usr/bin/env python3

import os
import yaml
from functions import *
from beeminderpy import Beeminder
import json
from pprint import pprint
import sys

directory = os.path.dirname(os.path.realpath(__file__))
config = yaml.load(open(f"{directory}/config.yaml", "r"))
b = Beeminder(config["auth"]["beeminder"]["token"])

pprint(sys.argv)

slug = sys.argv[1]
days_off = sys.argv[2:] if len(sys.argv) > 2 else None
username = config["auth"]["beeminder"]["username"]

print(slug, days_off)

goal = json.loads(b.get_goal(username, slug))
road = goal["roadall"]
new_road = add_breaks(road, days_off=days_off)

print("Old road:\r\n")
pprint(road)
print("\r\nNew road:\r\n")
pprint(new_road)

input = input(f"\r\nY to continue updating road for goal {slug}: ")

if input.lower() == "y":
    print("Updating road...")
    print(b.update_goal(username, slug, roadall=json.dumps(new_road)).text)
else:
    print("Aborting...")

functions.py

import time


def unix_to_string(unix, fmt="%Y-%m-%d"):
    return datetime.datetime.fromtimestamp(unix).strftime(fmt)


def add_breaks(roadall=None, days_off=None):
    def roadall_to_sparse_path(_roadall):
        return {segment[0]: segment[2] for segment in _roadall}

    def sparse_to_dense_path(_sparse_path):
        _dense_path = {}
        time_pointer = end_time
        while time_pointer > floor_time:
            if time_pointer in _sparse_path:
                _dense_path[time_pointer] = _sparse_path[time_pointer]
            else:
                _dense_path[time_pointer] = _dense_path[time_pointer + day_seconds]
            time_pointer -= day_seconds

        return _dense_path

    def set_days_off(_dense_path, _days_off):
        for the_time in _dense_path.keys():
            if the_time <= horizon:
                continue
            day_string = unix_to_string(the_time, fmt="%a")
            if day_string.lower() in [day.lower() for day in _days_off]:
                _dense_path[the_time] = 0

        return _dense_path

    def dense_path_to_roadall(_dense_path):
        if not _dense_path:
            return tail

        dense_keys_sorted = sorted(dense_path)
        new_road_reversed = []
        last_key = dense_keys_sorted[-1]
        new_road_reversed.append([last_key, None, dense_path[last_key]])
        for the_time in reversed(dense_keys_sorted):
            if dense_path[the_time] is not new_road_reversed[-1][2]:
                new_road_reversed.append([the_time, None, dense_path[the_time]])

        return tail + list(reversed(new_road_reversed))

    if roadall is None or roadall == []:
        return []

    if days_off is None:
        days_off = ["Fri", "Sat"]

    # noinspection PySimplifyBooleanCheck
    if days_off == []:
        return roadall

    day_seconds = 60 * 60 * 24
    horizon = time.time() + (day_seconds * 7)
    tail = [segment for segment in roadall if segment[0] <= horizon]
    tail = tail or [roadall[0]]
    floor_segment = tail[-1]
    floor_time = floor_segment[0]
    end_segment = roadall[-1]
    end_time = end_segment[0]

    sparse_path = roadall_to_sparse_path(roadall)
    dense_path = sparse_to_dense_path(sparse_path)
    dense_path_modified = set_days_off(dense_path, days_off)

    return dense_path_to_roadall(dense_path_modified)

beeminderpy.py

import urllib.error
import urllib.parse
import urllib.request
import requests

# based on https://www.beeminder.com/api
# Source: https://github.com/mattjoyce/beeminderpy/blob/master/beeminderpy.py


class Beeminder:
    def __init__(self, this_auth_token):
        self.auth_token = this_auth_token
        self.base_url = 'https://www.beeminder.com/api/v1'

    def get_user(self, username):
        url = "%s/users/%s.json" % (self.base_url, username)
        values = {
            'auth_token': self.auth_token
        }

        return self.call_api(url, values, 'GET')

    def get_goal(self, username, goalname):
        url = "%s/users/%s/goals/%s.json" % (self.base_url, username, goalname)
        values = {
            'auth_token': self.auth_token
        }

        return self.call_api(url, values, 'GET')

    def get_goals(self, username):
        url = "%s/users/%s/goals.json" % (self.base_url, username)
        values = {
            'auth_token': self.auth_token
        }

        return self.call_api(url, values, 'GET')

    def get_datapoints(self, username, goalname):
        url = "%s/users/%s/goals/%s/datapoints.json" % (self.base_url, username, goalname)
        values = {
            'auth_token': self.auth_token
        }

        return self.call_api(url, values, 'GET')

    def create_datapoint(self, username, goalname, timestamp, value, comment=' ', sendmail='false'):
        url = "%s/users/%s/goals/%s/datapoints.json" % (self.base_url, username, goalname)
        values = {
            'auth_token': self.auth_token,
            'timestamp': timestamp,
            'value': value,
            'comment': comment,
            'sendmail': sendmail
        }

        return self.call_api(url, values, 'POST')

    def update_datapoint(self, username, goalname, datapoint_id, timestamp=None, value=None, comment=None):
        url = "%s/users/%s/goals/%s/datapoints/%s.json" % (self.base_url, username, goalname, datapoint_id)
        values = {
            'auth_token': self.auth_token
        }

        if timestamp is not None:
            values['timestamp'] = timestamp

        if value is not None:
            values['value'] = value

        if comment is not None:
            values['comment'] = comment

        return self.call_api(url, values, 'PUT')

    def update_goal(self, username, goalname, slug=None, title=None, yaxis=None, secret=None, datapublic=None,
                    nomercy=None, roadall=None, datasource=None):
        url = "%s/users/%s/goals/%s.json" % (self.base_url, username, goalname)
        values = {
            'auth_token': self.auth_token
        }

        if slug is not None:
            values['slug'] = slug

        if title is not None:
            values['title'] = title

        if yaxis is not None:
            values['yaxis'] = yaxis

        if secret is not None:
            values['secret'] = secret

        if datapublic is not None:
            values['datapublic'] = datapublic

        if nomercy is not None:
            values['nomercy'] = nomercy

        if roadall is not None:
            values['roadall'] = roadall

        if datasource is not None:
            values['datasource'] = datasource

        return self.call_api(url, values, 'PUT')

    def call_api(self, url, values, method='GET'):
        if method == 'POST':
            return requests.post(url, values)
        elif method == 'PUT':
            return requests.put(url, values)
        else:
            data = urllib.parse.urlencode(values)
            response = urllib.request.urlopen(url + '?' + data)

        return response.read()
4 Likes
API request: Set rate for daterange
#2

Holy shit I’ve wanted this for so long. I went down this road (no pun) and gave up many years ago.

So, the real trick (IMHO) is can we extend this to edit the non-break sections of the road such that the value you enter on the road dial is what you’re actually forced to do? e.g. if I say N units per week and 3/7ths of days off, by default I’ll only end up doing 3N/7 units per week.

3 Likes
#3

Theoretically, yes. But why wouldn’t you just switch your rate to days instead of weeks?

2 Likes
#4

Ha! Of course. :slight_smile:

(Seeing this post brought my back to my original project where I was trying to handle whatever setup the user already had so that I could post it without caveats, but you’re right if I’m just using this thing for myself I can switch all my time units)

3 Likes
#5

Gotcha, yeah. And that certainly is a caveat with this script…

Also, once you’ve scheduled breaks for the rest of your road, changing the rate for all the non-break periods will require road editing (though, since you only need to edit existing segments, it’s not as bad as entering the breaks manually in the first place).

1 Like
#6

Oh, yeah, on that topic my suggestion is that the script only schedule breaks ~2 weeks into the future instead of all the way until the end of time. It reduces the accuracy of the number of safe days prediction, but my guess is that if you’re super in the green you don’t care about just how green?

(If you weren’t envisioning running this as a cron, I guess it’s a tradeoff to make but I’m already running various Beeminder crons so it’s just one more to put in the pile)

1 Like
#7

We could add that. My own use case requires breaks to the end of the road, and I’ll (theoretically) only be using it once for each goal. But I could see how people who want breaks on different days but don’t need that kind of accuracy might like to do what you’re suggesting.

Hmm, maybe this should be it’s own GitHub repository. :wink:

3 Likes
Narthur's Beeminder Journal
#8

I’ve committed to it:


3 Likes
#9

This sounds like it would be useful, alas, not being technically proficient I don’t know where to start. Could you break this down a bit more for a n00b?