Okay, since apparently I am the only one who cares about data integrity [edit: this sounds passive aggressive, but I am not really upset], I gave in to my “fine, I will do it myself moment”. Que music for epic montage of GPT and yours truly coding.
First, we came up with this beauty of a script. I created a new goal /focusmate_ for the data. To use the script, update the global variables and pip install keyring and pydantic.
Focusmate data to Beeminder completed sessions only
import requests
import os
import keyring
import sys
from datetime import datetime, timedelta
from pydantic import BaseModel
from typing import List, Optional
BEEMINDER_AUTH_TOKEN = keyring.get_password("beeminder", "felixm")
FOCUSMATE_API_KEY = keyring.get_password("focusmate-api-key", "felixm")
FOCUSMATE_BASE_URL = "https://api.focusmate.com/v1"
USER = "felixm"
GOAL = "focusmate_"
BEEMINDER_URL = f"https://www.beeminder.com/api/v1/users/{USER}/goals/{GOAL}/datapoints.json"
class User(BaseModel):
userId: str
requestedAt: Optional[str]
joinedAt: Optional[str]
completed: Optional[bool]
sessionTitle: Optional[str]
class Session(BaseModel):
sessionId: str
duration: int
startTime: str
users: List[User]
def get_timestamp(self) -> int:
return int(datetime.fromisoformat(self.startTime).timestamp())
class BeeminderDatapoint(BaseModel):
id: str
timestamp: int
daystamp: str
value: int
comment: str
updated_at: int
requestid: Optional[str]
def get_timestamp(self) -> int:
return self.timestamp
def get_user_name(user_id: str) -> Optional[str]:
response = requests.get(
f"{FOCUSMATE_BASE_URL}/users/{user_id}",
headers={"X-API-KEY": FOCUSMATE_API_KEY}
)
if response.status_code != 200:
print(f"Unable to fetch user data. Status code: {response.status_code}")
return None
data = response.json()
return data['user']['name']
def get_sessions(start_date: str, end_date: str) -> List[Session]:
response = requests.get(
f"{FOCUSMATE_BASE_URL}/sessions?start={start_date}&end={end_date}",
headers={"X-API-KEY": FOCUSMATE_API_KEY}
)
if response.status_code != 200:
print(f"Unable to fetch session data. Status code: {response.status_code}")
return []
data = response.json()
sessions = [Session.parse_obj(session) for session in data['sessions']]
return sessions
def post_to_beeminder(session: Session) -> Optional[dict]:
if not session.users[0].completed:
print(f"Did not add uncompleted session {session}.")
return None
if len(session.users) > 1:
partner_user = session.users[1]
partner_name = get_user_name(partner_user.userId)
else:
partner_name = "No partner."
session_title = session.users[0].sessionTitle
session_start_time = datetime.fromisoformat(session.startTime)
start_day = session_start_time.strftime("%a") # format start time as day of the week
duration_mins = session.duration // 60000 # milliseconds to minutes
comment = f"{start_day}, {duration_mins} mins, {partner_name}"
if session_title:
comment += f", {session_title}"
timestamp = session.get_timestamp()
# Convert session start time to "daystamp" (date in "yyyymmdd" format)
daystamp = session_start_time.strftime("%Y%m%d")
data = {
"auth_token": BEEMINDER_AUTH_TOKEN,
"value": 1,
"comment": comment,
"timestamp": timestamp,
"daystamp": daystamp
}
response = requests.post(BEEMINDER_URL, data=data)
if response.status_code != 200:
print(f"Unable to post to Beeminder. Status code: {response.status_code}")
return None
else:
print(f"Posted '{comment}' to Beeminder.")
return response.json()
def get_latest_datapoint() -> Optional[BeeminderDatapoint]:
params = {
"auth_token": BEEMINDER_AUTH_TOKEN,
"count": 1,
"sort": "timestamp"
}
response = requests.get(BEEMINDER_URL, params=params)
if response.status_code != 200:
print(f"Unable to fetch datapoint from Beeminder. Status code: {response.status_code}")
return None
data = response.json()
try:
# We asked for 1 datapoint, so we take the first one
return BeeminderDatapoint.parse_obj(data[0])
except IndexError:
return None
def update_sequentially():
# Step 1: Get latest datapoint
latest_datapoint = get_latest_datapoint()
if latest_datapoint is None or latest_datapoint.value == 0:
latest_timestamp = datetime(2015, 1, 1)
else:
# Convert timestamp to datetime object
latest_timestamp = datetime.fromtimestamp(latest_datapoint.timestamp)
current_timestamp = datetime.now()
while latest_timestamp < current_timestamp:
# Step 2: Get sessions
start_date = latest_timestamp.strftime("%Y-%m-%dT%H:%M:%SZ")
end_date = (latest_timestamp + timedelta(days=365)).strftime("%Y-%m-%dT%H:%M:%SZ")
sessions = get_sessions(start_date, end_date)
# Step 3: Add sessions to Beeminder
print(f"Got {len(sessions)} sessions between {start_date} and {end_date}.")
sessions.sort(key=lambda s: datetime.fromisoformat(s.startTime))
for session in sessions:
post_to_beeminder(session)
# Step 4: Set latest_timestamp to latest_timestamp + 1 year
latest_timestamp += timedelta(days=365)
if __name__ == "__main__":
update_sequentially()
That worked great, except:
FML. So, we wrote 100 additional lines of debugging code
Focusmate data to Beeminder but with debug information
import requests
import os
import keyring
import sys
from datetime import datetime, timedelta
from pydantic import BaseModel
from typing import List, Optional
BEEMINDER_AUTH_TOKEN = keyring.get_password("beeminder", "felixm")
FOCUSMATE_API_KEY = keyring.get_password("focusmate-api-key", "felixm")
FOCUSMATE_BASE_URL = "https://api.focusmate.com/v1"
USER = "felixm"
GOAL = "focusmate_"
BEEMINDER_URL = f"https://www.beeminder.com/api/v1/users/{USER}/goals/{GOAL}/datapoints.json"
class User(BaseModel):
userId: str
requestedAt: Optional[str]
joinedAt: Optional[str]
completed: Optional[bool]
sessionTitle: Optional[str]
class Session(BaseModel):
sessionId: str
duration: int
startTime: str
users: List[User]
def get_timestamp(self) -> int:
return int(datetime.fromisoformat(self.startTime).timestamp())
class BeeminderDatapoint(BaseModel):
id: str
timestamp: int
daystamp: str
value: int
comment: str
updated_at: int
requestid: Optional[str]
def get_timestamp(self) -> int:
return self.timestamp
def get_user_name(user_id: str) -> Optional[str]:
response = requests.get(
f"{FOCUSMATE_BASE_URL}/users/{user_id}",
headers={"X-API-KEY": FOCUSMATE_API_KEY}
)
if response.status_code != 200:
print(f"Unable to fetch user data. Status code: {response.status_code}")
return None
data = response.json()
return data['user']['name']
def get_sessions(start_date: str, end_date: str) -> List[Session]:
response = requests.get(
f"{FOCUSMATE_BASE_URL}/sessions?start={start_date}&end={end_date}",
headers={"X-API-KEY": FOCUSMATE_API_KEY}
)
if response.status_code != 200:
print(f"Unable to fetch session data. Status code: {response.status_code}")
return []
data = response.json()
sessions = [Session.parse_obj(session) for session in data['sessions']]
return sessions
def post_to_beeminder(session: Session) -> Optional[dict]:
if not session.users[0].completed:
print(f"Did not add uncompleted session {session}.")
return None
if len(session.users) > 1:
partner_user = session.users[1]
partner_name = get_user_name(partner_user.userId)
else:
partner_name = "No partner."
session_title = session.users[0].sessionTitle
session_start_time = datetime.fromisoformat(session.startTime)
start_day = session_start_time.strftime("%a") # format start time as day of the week
duration_mins = session.duration // 60000 # milliseconds to minutes
comment = f"{start_day}, {duration_mins} mins, {partner_name}"
if session_title:
comment += f", {session_title}"
timestamp = session.get_timestamp()
# Convert session start time to "daystamp" (date in "yyyymmdd" format)
daystamp = session_start_time.strftime("%Y%m%d")
data = {
"auth_token": BEEMINDER_AUTH_TOKEN,
"value": 1,
"comment": comment,
"timestamp": timestamp,
"daystamp": daystamp
}
response = requests.post(BEEMINDER_URL, data=data)
if response.status_code != 200:
print(f"Unable to post to Beeminder. Status code: {response.status_code}")
return None
else:
print(f"Posted '{comment}' to Beeminder.")
return response.json()
def get_latest_datapoint() -> Optional[BeeminderDatapoint]:
params = {
"auth_token": BEEMINDER_AUTH_TOKEN,
"count": 1,
"sort": "timestamp"
}
response = requests.get(BEEMINDER_URL, params=params)
if response.status_code != 200:
print(f"Unable to fetch datapoint from Beeminder. Status code: {response.status_code}")
return None
data = response.json()
try:
# We asked for 1 datapoint, so we take the first one
return BeeminderDatapoint.parse_obj(data[0])
except IndexError:
return None
def get_all_datapoints() -> List[BeeminderDatapoint]:
params = {
"auth_token": BEEMINDER_AUTH_TOKEN,
"sort": "timestamp"
}
response = requests.get(BEEMINDER_URL, params=params)
if response.status_code != 200:
print(f"Unable to fetch datapoint from Beeminder. Status code: {response.status_code}")
return None
data = response.json()
try:
return [BeeminderDatapoint.parse_obj(o) for o in data]
except IndexError:
return None
def update_sequentially():
# Step 1: Get latest datapoint
latest_datapoint = get_latest_datapoint()
if latest_datapoint is None or latest_datapoint.value == 0:
latest_timestamp = datetime(2015, 1, 1)
else:
# Convert timestamp to datetime object
latest_timestamp = datetime.fromtimestamp(latest_datapoint.timestamp)
current_timestamp = datetime.now()
while latest_timestamp < current_timestamp:
# Step 2: Get sessions
start_date = latest_timestamp.strftime("%Y-%m-%dT%H:%M:%SZ")
end_date = (latest_timestamp + timedelta(days=365)).strftime("%Y-%m-%dT%H:%M:%SZ")
sessions = get_sessions(start_date, end_date)
# Step 3: Add sessions to Beeminder
print(f"Got {len(sessions)} sessions between {start_date} and {end_date}.")
sessions.sort(key=lambda s: datetime.fromisoformat(s.startTime))
for session in sessions:
post_to_beeminder(session)
# Step 4: Set latest_timestamp to latest_timestamp + 1 year
latest_timestamp += timedelta(days=365)
def get_all_sessions() -> List[Session]:
sessions = []
latest_timestamp = datetime(2015, 1, 1)
current_timestamp = datetime.now()
while latest_timestamp < current_timestamp:
start_date = latest_timestamp.strftime("%Y-%m-%dT%H:%M:%SZ")
end_date = (latest_timestamp + timedelta(days=365)).strftime("%Y-%m-%dT%H:%M:%SZ")
sessions += get_sessions(start_date, end_date)
latest_timestamp += timedelta(days=365)
sessions.sort(key=lambda s: datetime.fromisoformat(s.startTime))
return sessions
def update():
def check_duplicates(datapoints: List):
timestamps = {}
for datapoint in datapoints:
try:
timestamps[datapoint.get_timestamp()].append(datapoint)
except KeyError:
timestamps[datapoint.get_timestamp()] = [datapoint]
all_okay = True
for timestamp, datapoints in timestamps.items():
if len(datapoints) > 1:
all_okay = False
print("[error] duplicates")
for d in datapoints:
print(d)
if all_okay:
print("[ok] no duplicates")
def post_missing_sessions(datapoints: List[BeeminderDatapoint], sessions: List[Session]):
""" Get FocusMate sessions that have not been posted to Beeminder yet and post them. """
beeminder_timestamps = [datapoint.timestamp for datapoint in datapoints]
missing_sessions = [session
for session in sessions
if session.get_timestamp() not in beeminder_timestamps]
for session in missing_sessions:
if session.users[0].completed == True:
post_to_beeminder(session)
else:
pass
print("[ok] All Focusmate sessions are in Beeminder")
def find_overposted_datapoints(datapoints: List[BeeminderDatapoint], sessions: List[Session]):
""" Get Beeminder sessions that are not in FocusMate. """
focusmate_timestamps = [session.get_timestamp() for session in sessions]
assert(len(focusmate_timestamps) == len(set(focusmate_timestamps)))
additional_datapoints = [datapoint
for datapoint in datapoints
if datapoint.timestamp not in focusmate_timestamps]
if additional_datapoints:
print("[error] Additional datapoints")
for a in additional_datapoints:
print(a)
else:
print("[ok] No additional Beeminder datapoints")
def check_beeminder_values(datapoints: List[BeeminderDatapoint]):
all_okay = True
for d in datapoints:
if d.value != 1:
print(f"[error] Beeminder datapoints with value != 1 {d=}")
all_okay = False
if all_okay:
print("[ok] All Beeminder datapoints have value == 1")
total_sessions = get_focusmate_total_sessions()
datapoints = get_all_datapoints()
sessions = get_all_sessions()
print(f"Beeminder sessions: {len(datapoints)}")
print(f"Focusmate official count: {total_sessions}")
print(f"Focusmate all session: {len(sessions)}")
check_duplicates(datapoints)
check_duplicates(sessions)
check_beeminder_values(datapoints)
post_missing_sessions(datapoints, sessions)
find_overposted_datapoints(datapoints, sessions)
def get_focusmate_total_sessions() -> int:
response = requests.get(
f"{FOCUSMATE_BASE_URL}/me",
headers={"X-API-KEY": FOCUSMATE_API_KEY}
)
if response.status_code != 200:
print(f"Unable to fetch user data. Status code: {response.status_code}")
return 0
data = response.json()
return data['user']['totalSessionCount']
if __name__ == "__main__":
update()
And got this:
[ok] no duplicates
[error] duplicates
// two sessions with identical timestamp, both completed=False
[error] duplicates
// two sessions with identical timestamp, one completed=True and one completed=False
[ok] All Beeminder datapoints have value == 1
[ok] All Focusmate sessions are in Beeminder
Traceback (most recent call last):
File "/home/felixm/tmp/focusmate/sync.py", line 289, in <module>
update()
File "/home/felixm/tmp/focusmate/sync.py", line 264, in update
find_overposted_datapoints(datapoints, sessions)
File "/home/felixm/tmp/focusmate/sync.py", line 232, in find_overposted_datapoints
assert(len(focusmate_timestamps) == len(set(focusmate_timestamps)))
AssertionError
Which completely threw me off because there is no reason why there couldn’t be two sessions at the same timestamp (as long as they are not both completed, I guess).
I removed the useless:
assert(len(focusmate_timestamps) == len(set(focusmate_timestamps)))
And, sure enough:
[ok] no duplicates
[ok] All Beeminder datapoints have value == 1
[ok] All Focusmate sessions are in Beeminder
[error] Additional datapoints
id='64651b00f0168a430fe3d3b7' timestamp=1675141199 daystamp='20230130' value=1 comment='Mon, 50 mins, Jian L.' updated_at=1684347973 requestid=None
I don’t know how Jian got added twice, but it has something to do with me locking my device and the script kinda getting stuck, and then after restarting it, the latest_timestamp
approach didn’t work as expected?
Anyways, I have removed the data point, and now I finally get a satisfactory:
Beeminder sessions: 856
Focusmate official count: 856
Focusmate all session: 899
[ok] no duplicates
[ok] All Beeminder datapoints have value == 1
[ok] All Focusmate sessions are in Beeminder
[ok] No additional Beeminder datapoints
I will use post_missing_sessions(datapoints, sessions)
to sync this goal in the future. It sucks that I have to query for all data points and sessions, but since I only do it once a day, it’s okay. It should be easy to fix update_sequentially
. If I do that I will post the update.
Now, I have a beautiful graph with retro-fitted red line: