From da9a8e9ef1315f36e4abae5c46ae9d388aad6c6c Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Thu, 21 Oct 2021 22:52:53 -0700 Subject: [PATCH] Improve date handling This project was processing dates without proper consideration of their timezones. As such, several bizarre errors errors were happening, especially around the start and end of a sprint. For example, issues closed at 6pm Arizona time on the last day of the sprint were not having their points credited to that day on the burndown chart because GitHub returned that closed date as 1am UTC the following day. This project now processes all dates internally using UTC. The `sprint_start_date` and `sprint_end_date` are assumed to be at 00:00:00 *local time*, and the final burndown chart is rendered in local time. These changes fix #17 and fix #21 --- .../chart/burndown.py | 34 +++---- .../gh/project.py | 88 +++++++++++-------- .../util/__init__.py | 0 .../util/dates.py | 21 +++++ 4 files changed, 92 insertions(+), 51 deletions(-) create mode 100644 src/github_projects_burndown_chart/util/__init__.py create mode 100644 src/github_projects_burndown_chart/util/dates.py diff --git a/src/github_projects_burndown_chart/chart/burndown.py b/src/github_projects_burndown_chart/chart/burndown.py index 899bd5f..e1e6101 100644 --- a/src/github_projects_burndown_chart/chart/burndown.py +++ b/src/github_projects_burndown_chart/chart/burndown.py @@ -3,23 +3,23 @@ from datetime import datetime from config import config from gh.project import Project +from util.dates import parse_to_local, parse_to_utc + class BurndownChart: def __init__(self, project: Project): - # Initialize important dates - self.start_date = datetime.strptime( - config['settings']['sprint_start_date'], - '%Y-%m-%d') - self.end_date = datetime.strptime( - config['settings']['sprint_end_date'], - '%Y-%m-%d') - self.project = project - + self.start_date_utc: datetime = parse_to_utc( + config['settings']['sprint_start_date']) + self.end_date_utc: datetime = parse_to_utc( + config['settings']['sprint_end_date']) + + self.project: Project = project + def render(self): - outstanding_points_by_day = self.project.outstanding_points_by_day( - self.start_date, - self.end_date) + outstanding_points_by_day = self.project.outstanding_points_by_date( + self.start_date_utc, + self.end_date_utc) # Load date dict for priority values with x being range of how many days are in sprint x = list(range(len(outstanding_points_by_day.keys()))) y = list(outstanding_points_by_day.values()) @@ -27,16 +27,18 @@ class BurndownChart: # Plot point values for sprint along xaxis=range yaxis=points over time plt.plot(x, y) plt.axline((x[0], self.project.total_points), - slope=-(self.project.total_points/(len(y)-1)), - color="green", - linestyle=(0, (5, 5))) + slope=-(self.project.total_points/(len(y)-1)), + color="green", + linestyle=(0, (5, 5))) # Set sprint beginning plt.ylim(ymin=0) plt.xlim(xmin=x[0], xmax=x[-1]) # Replace xaxis range for date matching to range value - plt.xticks(x, outstanding_points_by_day.keys()) + date_labels = [str(parse_to_local(date))[:10] + for date in outstanding_points_by_day.keys()] + plt.xticks(x, date_labels) plt.xticks(rotation=90) # Set titles and labels diff --git a/src/github_projects_burndown_chart/gh/project.py b/src/github_projects_burndown_chart/gh/project.py index 29871db..5e003b7 100644 --- a/src/github_projects_burndown_chart/gh/project.py +++ b/src/github_projects_burndown_chart/gh/project.py @@ -1,6 +1,9 @@ from datetime import datetime, timedelta +from typing import Dict +from dateutil.parser import isoparse from config import config +from util.dates import TODAY_UTC class Project: @@ -17,33 +20,52 @@ class Project: def total_points(self): return sum([column.get_total_points() for column in self.columns]) - def points_completed_by_date(self, start_date, end_date): - points_completed_by_date = { - str(date)[:10] : 0 - for date in [ - start_date + timedelta(days=x) - for x in range(0, (end_date - start_date).days + 1) - ] - } - for column in self.columns: - for card in column.cards: - if card.closedAt: - date_str = str(card.closedAt)[:10] - points_completed_by_date[date_str] += card.points + def points_completed_by_date(self, start_date: datetime, end_date: datetime) -> Dict[datetime, int]: + """Computes the number of points completed by date. + Basically the data behind a burnup chart for the given date range. + + Args: + start_date (datetime): The start date of the chart in UTC. + end_date (datetime): The end date of the chart in UTC. + + Returns: + Dict[datetime, int]: A dictionary of date and points completed. + """ + points_completed_by_date = {} + + cards = [card for column in self.columns for card in column.cards] + completed_cards = [card for card in cards if card.closedAt is not None] + sprint_dates = [start_date + timedelta(days=x) + # The +1 includes the end_date in the list + for x in range(0, (end_date - start_date).days + 1)] + for date in sprint_dates: + # Get the issues completed before midnight on the given date. + date_23_59 = date + timedelta(hours=23, minutes=59) + cards_done_by_date = [card for card in completed_cards + if card.closedAt <= date_23_59] + points_completed_by_date[date] = sum([card.points for card + in cards_done_by_date]) return points_completed_by_date - def outstanding_points_by_day(self, start_date, end_date): - outstanding_points_by_day = {} - points_completed = 0 - points_completed_by_date = self.points_completed_by_date(start_date, end_date) - current_date = datetime.now() - for date in points_completed_by_date: - points_completed += points_completed_by_date[date] - if datetime.strptime(date, '%Y-%m-%d') < current_date: - outstanding_points_by_day[date] = self.total_points - points_completed - else: - outstanding_points_by_day[date] = None - return outstanding_points_by_day + def outstanding_points_by_date(self, start_date: datetime, end_date: datetime) -> Dict[datetime, int]: + """Computes the number of points remaining to be completed by date. + Basically the data behind a burndown chart for the given date range. + + Args: + start_date (datetime): The start date of the chart in UTC. + end_date (datetime): The end date of the chart in UTC. + + Returns: + Dict[datetime, int]: A dictionary of date and points remaining. + """ + points_completed_by_date = self.points_completed_by_date( + start_date, end_date) + today_23_59 = TODAY_UTC + timedelta(hours=23, minutes=59) + return { + date: self.total_points - points_completed_by_date[date] + if date <= today_23_59 else None + for date in points_completed_by_date + } class Column: @@ -69,17 +91,13 @@ class Card: def __parse_createdAt(self, card_data): createdAt = None if card_data.get('createdAt'): - createdAt = datetime.strptime( - card_data['createdAt'][:10], - '%Y-%m-%d') + createdAt = isoparse(card_data['createdAt']) return createdAt def __parse_closedAt(self, card_data): closedAt = None if card_data.get('closedAt'): - closedAt = datetime.strptime( - card_data['closedAt'][:10], - '%Y-%m-%d') + closedAt = isoparse(card_data['closedAt']) return closedAt def __parse_points(self, card_data): @@ -89,7 +107,7 @@ class Card: card_points = 1 else: card_labels = card_data.get('labels', {"nodes": []})['nodes'] - for label in card_labels: - if points_label in label['name']: - card_points += int(label['name'][len(points_label):]) - return card_points \ No newline at end of file + card_points = sum([int(label['name'][len(points_label):]) + for label in card_labels + if points_label in label['name']]) + return card_points diff --git a/src/github_projects_burndown_chart/util/__init__.py b/src/github_projects_burndown_chart/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/github_projects_burndown_chart/util/dates.py b/src/github_projects_burndown_chart/util/dates.py new file mode 100644 index 0000000..5f0a68e --- /dev/null +++ b/src/github_projects_burndown_chart/util/dates.py @@ -0,0 +1,21 @@ +from datetime import datetime, timedelta, timezone +from dateutil import parser + +UTC_OFFSET: timedelta = datetime.utcnow() - datetime.now() +"""Local time + UTC_OFFSET = UTC Time""" + +def parse_to_utc(date_string: str) -> datetime: + """ + Parse a date string to UTC time. + """ + raw_datetime = parser.parse(date_string) + UTC_OFFSET + datetime_utc = raw_datetime.replace(tzinfo=timezone.utc) + return datetime_utc + +def parse_to_local(datetime_utc: datetime) -> datetime: + """ + Parse a datetime object to local time. + """ + return datetime_utc.astimezone() + +TODAY_UTC: datetime = parse_to_utc(datetime.today().strftime('%Y-%m-%d')) \ No newline at end of file -- 2.51.2