Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add human friendly comparison periods toggle
Browse files Browse the repository at this point in the history
This toggle in the backend allows us to do "human friendly" comparisons, i.e. consider month as 52 weeks and month as 4 weeks. This makes the comparison table slightly nicer to look at, at the cost of not aligning 100% of the time because a month = 30d and a year=365s are slightly more than 4 weeks and 52 weeks, respectively

Follow-up commit will add the toggle to the UI
rafaeelaudibert committed Dec 27, 2024

Verified

This commit was signed with the committer’s verified signature.
1 parent 79a1c30 commit 5a2ab1c
Showing 6 changed files with 134 additions and 43 deletions.
7 changes: 6 additions & 1 deletion posthog/hogql_queries/utils/query_compare_to_date_range.py
Original file line number Diff line number Diff line change
@@ -33,7 +33,12 @@ def dates(self) -> tuple[datetime, datetime]:
current_period_date_from = super().date_from()
current_period_date_to = super().date_to()

start_date = relative_date_parse(self.compare_to, self._team.timezone_info, now=current_period_date_from)
start_date = relative_date_parse(
self.compare_to,
self._team.timezone_info,
now=current_period_date_from,
human_friendly_comparison_periods=self._team.human_friendly_comparison_periods,
)

return (
start_date,
Original file line number Diff line number Diff line change
@@ -41,3 +41,40 @@ def test_feb(self):
)
self.assertEqual(query_date_range.date_from(), parser.isoparse("2021-02-28T00:00:00Z"))
self.assertEqual(query_date_range.date_to(), parser.isoparse("2021-03-02T23:59:59.999999Z"))

# Same as above but with human friendly comparison periods, should use week instead of month/year
def test_minus_one_month_human_friendly(self):
self.team.human_friendly_comparison_periods = True

now = parser.isoparse("2021-08-25T00:00:00.000Z")
date_range = DateRange(date_from="-48h")
query_date_range = QueryCompareToDateRange(
team=self.team,
date_range=date_range,
interval=IntervalType.DAY,
now=now,
compare_to="-1m",
)
self.assertEqual(query_date_range.date_from(), parser.isoparse("2021-07-26T00:00:00Z"))
self.assertEqual(query_date_range.date_to(), parser.isoparse("2021-07-28T23:59:59.999999Z"))

# Human friendly comparison periods guarantee that the end of the week is same day
self.assertEqual(query_date_range.date_to().isoweekday(), now.isoweekday())

def test_minus_one_year_human_friendly(self):
self.team.human_friendly_comparison_periods = True

now = parser.isoparse("2021-08-25T00:00:00.000Z")
date_range = DateRange(date_from="-48h")
query_date_range = QueryCompareToDateRange(
team=self.team,
date_range=date_range,
interval=IntervalType.DAY,
now=now,
compare_to="-1y",
)
self.assertEqual(query_date_range.date_from(), parser.isoparse("2020-08-24T00:00:00Z"))
self.assertEqual(query_date_range.date_to(), parser.isoparse("2020-08-26T23:59:59.999999Z"))

# Human friendly comparison periods guarantee that the end of the week is same day
self.assertEqual(query_date_range.date_to().isoweekday(), now.isoweekday())
17 changes: 17 additions & 0 deletions posthog/migrations/0537_team_human_friendly_comparison_periods.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.15 on 2024-12-27 19:22

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("posthog", "0536_alertconfiguration_skip_weekend"),
]

operations = [
migrations.AddField(
model_name="team",
name="human_friendly_comparison_periods",
field=models.BooleanField(default=False),
),
]
2 changes: 1 addition & 1 deletion posthog/migrations/max_migration.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0536_alertconfiguration_skip_weekend
0537_team_human_friendly_comparison_periods
1 change: 1 addition & 0 deletions posthog/models/team/team.py
Original file line number Diff line number Diff line change
@@ -282,6 +282,7 @@ class Meta:
person_display_name_properties: ArrayField = ArrayField(models.CharField(max_length=400), null=True, blank=True)
live_events_columns: ArrayField = ArrayField(models.TextField(), null=True, blank=True)
recording_domains: ArrayField = ArrayField(models.CharField(max_length=200, null=True), blank=True, null=True)
human_friendly_comparison_periods = models.BooleanField(default=False, null=False)
cookieless_server_hash_mode = models.SmallIntegerField(
default=CookielessServerHashMode.DISABLED, choices=CookielessServerHashMode.choices, null=True
)
113 changes: 72 additions & 41 deletions posthog/utils.py
Original file line number Diff line number Diff line change
@@ -174,6 +174,7 @@ def relative_date_parse_with_delta_mapping(
timezone_info: ZoneInfo,
*,
always_truncate: bool = False,
human_friendly_comparison_periods: bool = False,
now: Optional[datetime.datetime] = None,
increase: bool = False,
) -> tuple[datetime.datetime, Optional[dict[str, int]], str | None]:
@@ -201,83 +202,113 @@ def relative_date_parse_with_delta_mapping(
parsed_dt = parsed_dt.astimezone(timezone_info)
return parsed_dt, None, None

regex = r"\-?(?P<number>[0-9]+)?(?P<type>[a-zA-Z])(?P<position>Start|End)?"
regex = r"\-?(?P<number>[0-9]+)?(?P<kind>[hdwmqyHDWMQY])(?P<position>Start|End)?"
match = re.search(regex, input)
parsed_dt = (now or dt.datetime.now()).astimezone(timezone_info)
delta_mapping: dict[str, int] = {}
if not match:
return parsed_dt, delta_mapping, None
elif match.group("type") == "h":
if match.group("number"):
delta_mapping["hours"] = int(match.group("number"))
if match.group("position") == "Start":

delta_mapping = get_delta_mapping_for(
**match.groupdict(),
human_friendly_comparison_periods=human_friendly_comparison_periods,
)

if increase:
parsed_dt += relativedelta(**delta_mapping) # type: ignore
else:
parsed_dt -= relativedelta(**delta_mapping) # type: ignore

if always_truncate:
# Truncate to the start of the hour for hour-precision datetimes, to the start of the day for larger intervals
# TODO: Remove this from this function, this should not be the responsibility of it
if "hours" in delta_mapping:
parsed_dt = parsed_dt.replace(minute=0, second=0, microsecond=0)
else:
parsed_dt = parsed_dt.replace(hour=0, minute=0, second=0, microsecond=0)
return parsed_dt, delta_mapping, match.group("position") or None


def get_delta_mapping_for(
*,
kind: str,
number: Optional[str] = None,
position: Optional[str] = None,
human_friendly_comparison_periods: bool = False,
) -> dict[str, int]:
delta_mapping: dict[str, int] = {}

if kind == "h":
if number:
delta_mapping["hours"] = int(number)
if position == "Start":
delta_mapping["minute"] = 0
delta_mapping["second"] = 0
delta_mapping["microsecond"] = 0
elif match.group("position") == "End":
elif position == "End":
delta_mapping["minute"] = 59
delta_mapping["second"] = 59
delta_mapping["microsecond"] = 999999
elif match.group("type") == "d":
if match.group("number"):
delta_mapping["days"] = int(match.group("number"))
if match.group("position") == "Start":
elif kind == "d":
if number:
delta_mapping["days"] = int(number)
if position == "Start":
delta_mapping["hour"] = 0
delta_mapping["minute"] = 0
delta_mapping["second"] = 0
delta_mapping["microsecond"] = 0
elif match.group("position") == "End":
elif position == "End":
delta_mapping["hour"] = 23
delta_mapping["minute"] = 59
delta_mapping["second"] = 59
delta_mapping["microsecond"] = 999999
elif match.group("type") == "w":
if match.group("number"):
delta_mapping["weeks"] = int(match.group("number"))
elif match.group("type") == "m":
if match.group("number"):
delta_mapping["months"] = int(match.group("number"))
if match.group("position") == "Start":
elif kind == "w":
if number:
delta_mapping["weeks"] = int(number)
elif kind == "m":
if number:
if human_friendly_comparison_periods:
delta_mapping["weeks"] = 4
else:
delta_mapping["months"] = int(number)
if position == "Start":
delta_mapping["day"] = 1
elif match.group("position") == "End":
elif position == "End":
delta_mapping["day"] = 31
elif match.group("type") == "q":
if match.group("number"):
delta_mapping["weeks"] = 13 * int(match.group("number"))
elif match.group("type") == "y":
if match.group("number"):
delta_mapping["years"] = int(match.group("number"))
if match.group("position") == "Start":
elif kind == "q":
if number:
delta_mapping["weeks"] = 13 * int(number)
elif kind == "y":
if number:
if human_friendly_comparison_periods:
delta_mapping["weeks"] = 52
else:
delta_mapping["years"] = int(number)
if position == "Start":
delta_mapping["month"] = 1
delta_mapping["day"] = 1
elif match.group("position") == "End":
elif position == "End":
delta_mapping["day"] = 31

if increase:
parsed_dt += relativedelta(**delta_mapping) # type: ignore
else:
parsed_dt -= relativedelta(**delta_mapping) # type: ignore

if always_truncate:
# Truncate to the start of the hour for hour-precision datetimes, to the start of the day for larger intervals
# TODO: Remove this from this function, this should not be the responsibility of it
if "hours" in delta_mapping:
parsed_dt = parsed_dt.replace(minute=0, second=0, microsecond=0)
else:
parsed_dt = parsed_dt.replace(hour=0, minute=0, second=0, microsecond=0)
return parsed_dt, delta_mapping, match.group("position") or None
return delta_mapping


def relative_date_parse(
input: str,
timezone_info: ZoneInfo,
*,
always_truncate: bool = False,
human_friendly_comparison_periods: bool = False,
now: Optional[datetime.datetime] = None,
increase: bool = False,
) -> datetime.datetime:
return relative_date_parse_with_delta_mapping(
input, timezone_info, always_truncate=always_truncate, now=now, increase=increase
input,
timezone_info,
always_truncate=always_truncate,
human_friendly_comparison_periods=human_friendly_comparison_periods,
now=now,
increase=increase,
)[0]


0 comments on commit 5a2ab1c

Please sign in to comment.