Skip to content

Commit a4b4198

Browse files
committed
Trend card with selectable displays
1 parent c2772ea commit a4b4198

8 files changed

Lines changed: 187 additions & 116 deletions

File tree

plain-pageviews/plain/pageviews/staff.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
from .models import Pageview
1010

1111

12-
class DailyPageviewsCard(TrendCard):
13-
title = "Daily pageviews"
12+
class PageviewsTrendCard(TrendCard):
13+
title = "Pageviews trend"
1414
model = Pageview
1515
datetime_field = "timestamp"
1616
size = TrendCard.Sizes.FULL
@@ -24,7 +24,7 @@ class ListView(StaffModelListView):
2424
title = "Pageviews"
2525
fields = ["user_id", "url", "timestamp", "session_key"]
2626
search_fields = ["pk", "user_id", "url", "session_key"]
27-
cards = [DailyPageviewsCard]
27+
cards = [PageviewsTrendCard]
2828

2929
class DetailView(StaffModelDetailView):
3030
model = Pageview

plain-staff/plain/staff/cards/base.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class Sizes(Enum):
2727
text: str = ""
2828
link: str = ""
2929
number: int | None = None
30+
displays: list[str] | Enum | None = None
3031

3132
# These will be accessible at render time
3233
view: View
@@ -43,12 +44,17 @@ def view_name(cls) -> str:
4344

4445
def get_template_context(self):
4546
context = {}
47+
48+
context["size"] = self.size
4649
context["title"] = self.get_title()
4750
context["slug"] = self.get_slug()
4851
context["description"] = self.get_description()
4952
context["number"] = self.get_number()
5053
context["text"] = self.get_text()
5154
context["link"] = self.get_link()
55+
context["displays"] = self.get_displays()
56+
context["current_display"] = self.get_current_display()
57+
5258
return context
5359

5460
def get_title(self) -> str:
@@ -68,3 +74,13 @@ def get_text(self) -> str:
6874

6975
def get_link(self) -> str:
7076
return self.link
77+
78+
def get_current_display(self) -> str:
79+
return self.request.GET.get(f"{self.get_slug()}.display", "")
80+
81+
def get_displays(self) -> list[str] | Enum | None:
82+
if hasattr(self.displays, "copy"):
83+
# Avoid mutating the class attribute
84+
return self.displays.copy()
85+
else:
86+
return self.displays

plain-staff/plain/staff/cards/charts.py

Lines changed: 64 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
1-
import enum
21
from collections import defaultdict
32

43
from plain.models import Count
54
from plain.models.functions import (
65
TruncDate,
76
TruncMonth,
8-
TruncQuarter,
9-
TruncWeek,
10-
TruncYear,
117
)
12-
from plain.staff.dates import DatetimeRange, DatetimeRangeAliases
8+
from plain.staff.dates import DatetimeRangeAliases
139

1410
from .base import Card
1511

@@ -34,40 +30,35 @@ class TrendCard(ChartCard):
3430

3531
model = None
3632
datetime_field = None
37-
datetime_range = DatetimeRangeAliases.SINCE_30_DAYS_AGO
33+
default_display = DatetimeRangeAliases.SINCE_30_DAYS_AGO
3834

39-
class Buckets(enum.Enum):
40-
DAY = "day"
41-
WEEK = "week"
42-
MONTH = "month"
43-
QUARTER = "quarter"
44-
YEAR = "year"
35+
displays = DatetimeRangeAliases
4536

46-
bucket_by = Buckets.DAY
37+
def get_description(self):
38+
datetime_range = DatetimeRangeAliases.to_range(self.get_current_display())
39+
return f"{datetime_range.start} to {datetime_range.end}"
4740

48-
def get_description(self) -> str:
49-
return self.datetime_range.value
50-
51-
def get_trend_datetime_range(self) -> DatetimeRange:
52-
return DatetimeRangeAliases.to_range(self.datetime_range)
41+
def get_current_display(self):
42+
if s := super().get_current_display():
43+
return DatetimeRangeAliases.from_value(s)
44+
return self.default_display
5345

5446
def get_trend_data(self) -> list[int | float]:
5547
if not self.model or not self.datetime_field:
5648
raise NotImplementedError(
5749
"model and datetime_field must be set, or get_values must be overridden"
5850
)
5951

60-
datetime_range = self.get_trend_datetime_range()
52+
datetime_range = DatetimeRangeAliases.to_range(self.get_current_display())
6153

6254
filter_kwargs = {f"{self.datetime_field}__range": datetime_range.as_tuple()}
6355

64-
truncator = {
65-
self.Buckets.DAY: TruncDate,
66-
self.Buckets.WEEK: TruncWeek,
67-
self.Buckets.MONTH: TruncMonth,
68-
self.Buckets.QUARTER: TruncQuarter,
69-
self.Buckets.YEAR: TruncYear,
70-
}[self.bucket_by]
56+
if datetime_range.total_days() < 300:
57+
truncator = TruncDate
58+
iterator = datetime_range.iter_days
59+
else:
60+
truncator = TruncMonth
61+
iterator = datetime_range.iter_months
7162

7263
counts_by_date = (
7364
self.model.objects.filter(**filter_kwargs)
@@ -82,30 +73,46 @@ def get_trend_data(self) -> list[int | float]:
8273
for row in counts_by_date:
8374
date_values[row["chart_date"]] = row["chart_date_count"]
8475

85-
# Now get the filled data for our date range
86-
iterator = {
87-
self.Buckets.DAY: datetime_range.iter_days,
88-
self.Buckets.WEEK: datetime_range.iter_weeks,
89-
self.Buckets.MONTH: datetime_range.iter_months,
90-
self.Buckets.QUARTER: datetime_range.iter_quarters,
91-
self.Buckets.YEAR: datetime_range.iter_years,
92-
}[self.bucket_by]
93-
return [date_values[date] for date in iterator()]
94-
95-
def get_trend_labels(self) -> list[str]:
96-
datetime_range = self.get_trend_datetime_range()
97-
iterator = {
98-
self.Buckets.DAY: datetime_range.iter_days,
99-
self.Buckets.WEEK: datetime_range.iter_weeks,
100-
self.Buckets.MONTH: datetime_range.iter_months,
101-
self.Buckets.QUARTER: datetime_range.iter_quarters,
102-
self.Buckets.YEAR: datetime_range.iter_years,
103-
}[self.bucket_by]
104-
return [date.strftime("%Y-%m-%d") for date in iterator()]
76+
return {date.strftime("%Y-%m-%d"): date_values[date] for date in iterator()}
10577

10678
def get_chart_data(self) -> dict:
107-
trend_labels = self.get_trend_labels()
108-
trend_data = self.get_trend_data()
79+
data = self.get_trend_data()
80+
trend_labels = list(data.keys())
81+
trend_data = list(data.values())
82+
83+
def calculate_trend_line(data):
84+
"""
85+
Calculate a trend line using basic linear regression.
86+
:param data: A list of numeric values representing the y-axis.
87+
:return: A list of trend line values (same length as data).
88+
"""
89+
if not data or len(data) < 2:
90+
return (
91+
data # Return the data as-is if not enough points for a trend line
92+
)
93+
94+
n = len(data)
95+
x = list(range(n))
96+
y = data
97+
98+
# Calculate the means of x and y
99+
x_mean = sum(x) / n
100+
y_mean = sum(y) / n
101+
102+
# Calculate the slope (m) and y-intercept (b) of the line: y = mx + b
103+
numerator = sum((x[i] - x_mean) * (y[i] - y_mean) for i in range(n))
104+
denominator = sum((x[i] - x_mean) ** 2 for i in range(n))
105+
slope = numerator / denominator if denominator != 0 else 0
106+
intercept = y_mean - slope * x_mean
107+
108+
# Calculate the trend line values
109+
trend = [slope * xi + intercept for xi in x]
110+
111+
# if it's all zeros, return nothing
112+
if all(v == 0 for v in trend):
113+
return []
114+
115+
return trend
109116

110117
return {
111118
"type": "bar",
@@ -114,7 +121,15 @@ def get_chart_data(self) -> dict:
114121
"datasets": [
115122
{
116123
"data": trend_data,
117-
}
124+
},
125+
{
126+
"data": calculate_trend_line(trend_data),
127+
"type": "line",
128+
"borderColor": "rgba(0, 0, 0, 0.3)",
129+
"borderWidth": 2,
130+
"fill": False,
131+
"pointRadius": 0, # Optional: Hide points
132+
},
118133
],
119134
},
120135
# Hide the label

plain-staff/plain/staff/dates.py

Lines changed: 59 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import datetime
2+
from calendar import monthrange
23
from enum import Enum
34

45
from plain.utils import timezone
@@ -35,72 +36,90 @@ class DatetimeRangeAliases(Enum):
3536
# TODO doesn't include anything less than a day...
3637
# ex. SINCE_1_HOUR_AGO = "Since 1 Hour Ago"
3738

39+
def __str__(self):
40+
return self.value
41+
42+
@classmethod
43+
def from_value(cls, value):
44+
for member in cls:
45+
if member.value == value:
46+
return member
47+
raise ValueError(f"{value} is not a valid value for {cls.__name__}")
48+
3849
@classmethod
39-
def to_range(cls, value: str) -> (datetime.datetime, datetime.datetime):
50+
def to_range(cls, value: str) -> tuple[datetime.datetime, datetime.datetime]:
4051
now = timezone.localtime()
41-
start_of_week = now - datetime.timedelta(days=now.weekday())
42-
start_of_month = now.replace(day=1)
43-
start_of_quarter = now.replace(month=((now.month - 1) // 3) * 3 + 1, day=1)
44-
start_of_year = now.replace(month=1, day=1)
52+
start_of_today = now.replace(hour=0, minute=0, second=0, microsecond=0)
53+
start_of_week = start_of_today - datetime.timedelta(
54+
days=start_of_today.weekday()
55+
)
56+
start_of_month = start_of_today.replace(day=1)
57+
start_of_quarter = start_of_today.replace(
58+
month=((start_of_today.month - 1) // 3) * 3 + 1, day=1
59+
)
60+
start_of_year = start_of_today.replace(month=1, day=1)
61+
62+
def end_of_day(dt):
63+
return dt.replace(hour=23, minute=59, second=59, microsecond=999999)
64+
65+
def end_of_month(dt):
66+
last_day = monthrange(dt.year, dt.month)[1]
67+
return end_of_day(dt.replace(day=last_day))
68+
69+
def end_of_quarter(dt):
70+
end_month = ((dt.month - 1) // 3 + 1) * 3
71+
return end_of_month(dt.replace(month=end_month))
72+
73+
def end_of_year(dt):
74+
return end_of_month(dt.replace(month=12))
4575

4676
if value == cls.TODAY:
47-
return DatetimeRange(now, now)
77+
return DatetimeRange(start_of_today, end_of_day(now))
4878
if value == cls.THIS_WEEK:
4979
return DatetimeRange(
50-
start_of_week, start_of_week + datetime.timedelta(days=6)
80+
start_of_week, end_of_day(start_of_week + datetime.timedelta(days=6))
5181
)
5282
if value == cls.THIS_WEEK_TO_DATE:
5383
return DatetimeRange(start_of_week, now)
5484
if value == cls.THIS_MONTH:
55-
return DatetimeRange(
56-
start_of_month, start_of_month + datetime.timedelta(days=31)
57-
)
85+
return DatetimeRange(start_of_month, end_of_month(start_of_month))
5886
if value == cls.THIS_MONTH_TO_DATE:
5987
return DatetimeRange(start_of_month, now)
6088
if value == cls.THIS_QUARTER:
61-
return DatetimeRange(
62-
start_of_quarter, start_of_quarter + datetime.timedelta(days=90)
63-
)
89+
return DatetimeRange(start_of_quarter, end_of_quarter(start_of_quarter))
6490
if value == cls.THIS_QUARTER_TO_DATE:
6591
return DatetimeRange(start_of_quarter, now)
6692
if value == cls.THIS_YEAR:
67-
return DatetimeRange(
68-
start_of_year,
69-
start_of_year.replace(year=start_of_year.year + 1)
70-
- datetime.timedelta(days=1),
71-
)
93+
return DatetimeRange(start_of_year, end_of_year(start_of_year))
7294
if value == cls.THIS_YEAR_TO_DATE:
7395
return DatetimeRange(start_of_year, now)
7496
if value == cls.LAST_WEEK:
97+
last_week_start = start_of_week - datetime.timedelta(days=7)
7598
return DatetimeRange(
76-
start_of_week - datetime.timedelta(days=7),
77-
start_of_week - datetime.timedelta(days=1),
99+
last_week_start,
100+
end_of_day(last_week_start + datetime.timedelta(days=6)),
78101
)
79102
if value == cls.LAST_WEEK_TO_DATE:
80103
return DatetimeRange(start_of_week - datetime.timedelta(days=7), now)
81104
if value == cls.LAST_MONTH:
82105
last_month = (start_of_month - datetime.timedelta(days=1)).replace(day=1)
83-
return DatetimeRange(
84-
last_month, last_month.replace(day=1) + datetime.timedelta(days=31)
85-
)
106+
return DatetimeRange(last_month, end_of_month(last_month))
86107
if value == cls.LAST_MONTH_TO_DATE:
87108
last_month = (start_of_month - datetime.timedelta(days=1)).replace(day=1)
88109
return DatetimeRange(last_month, now)
89110
if value == cls.LAST_QUARTER:
90111
last_quarter = (start_of_quarter - datetime.timedelta(days=1)).replace(
91112
day=1
92113
)
93-
return DatetimeRange(
94-
last_quarter, last_quarter + datetime.timedelta(days=90)
95-
)
114+
return DatetimeRange(last_quarter, end_of_quarter(last_quarter))
96115
if value == cls.LAST_QUARTER_TO_DATE:
97116
last_quarter = (start_of_quarter - datetime.timedelta(days=1)).replace(
98117
day=1
99118
)
100119
return DatetimeRange(last_quarter, now)
101120
if value == cls.LAST_YEAR:
102121
last_year = start_of_year.replace(year=start_of_year.year - 1)
103-
return DatetimeRange(last_year, start_of_year - datetime.timedelta(days=1))
122+
return DatetimeRange(last_year, end_of_year(last_year))
104123
if value == cls.LAST_YEAR_TO_DATE:
105124
last_year = start_of_year.replace(year=start_of_year.year - 1)
106125
return DatetimeRange(last_year, now)
@@ -113,28 +132,24 @@ def to_range(cls, value: str) -> (datetime.datetime, datetime.datetime):
113132
if value == cls.SINCE_365_DAYS_AGO:
114133
return DatetimeRange(now - datetime.timedelta(days=365), now)
115134
if value == cls.NEXT_WEEK:
135+
next_week_start = start_of_week + datetime.timedelta(days=7)
116136
return DatetimeRange(
117-
start_of_week + datetime.timedelta(days=7),
118-
start_of_week + datetime.timedelta(days=13),
137+
next_week_start,
138+
end_of_day(next_week_start + datetime.timedelta(days=6)),
119139
)
120140
if value == cls.NEXT_4_WEEKS:
121-
return DatetimeRange(now, now + datetime.timedelta(days=28))
141+
return DatetimeRange(now, end_of_day(now + datetime.timedelta(days=28)))
122142
if value == cls.NEXT_MONTH:
123143
next_month = (start_of_month + datetime.timedelta(days=31)).replace(day=1)
124-
return DatetimeRange(next_month, next_month + datetime.timedelta(days=31))
144+
return DatetimeRange(next_month, end_of_month(next_month))
125145
if value == cls.NEXT_QUARTER:
126146
next_quarter = (start_of_quarter + datetime.timedelta(days=90)).replace(
127147
day=1
128148
)
129-
return DatetimeRange(
130-
next_quarter, next_quarter + datetime.timedelta(days=90)
131-
)
149+
return DatetimeRange(next_quarter, end_of_quarter(next_quarter))
132150
if value == cls.NEXT_YEAR:
133151
next_year = start_of_year.replace(year=start_of_year.year + 1)
134-
return DatetimeRange(
135-
next_year,
136-
next_year.replace(year=next_year.year + 1) - datetime.timedelta(days=1),
137-
)
152+
return DatetimeRange(next_year, end_of_year(next_year))
138153
raise ValueError(f"Invalid range: {value}")
139154

140155

@@ -149,12 +164,16 @@ def __init__(self, start, end):
149164
if isinstance(self.end, str) and self.end:
150165
self.end = datetime.datetime.fromisoformat(self.end)
151166

152-
if isinstance(self.start, datetime.date):
167+
if isinstance(self.start, datetime.date) and not isinstance(
168+
self.start, datetime.datetime
169+
):
153170
self.start = timezone.localtime().replace(
154171
year=self.start.year, month=self.start.month, day=self.start.day
155172
)
156173

157-
if isinstance(self.end, datetime.date):
174+
if isinstance(self.end, datetime.date) and not isinstance(
175+
self.start, datetime.datetime
176+
):
158177
self.end = timezone.localtime().replace(
159178
year=self.end.year, month=self.end.month, day=self.end.day
160179
)

0 commit comments

Comments
 (0)