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()