studytimer-analytics 1.0.0__tar.gz → 1.0.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {studytimer_analytics-1.0.0 → studytimer_analytics-1.0.3}/PKG-INFO +1 -11
- studytimer_analytics-1.0.3/README.md +2 -0
- {studytimer_analytics-1.0.0 → studytimer_analytics-1.0.3}/pyproject.toml +3 -2
- studytimer_analytics-1.0.3/studytimer_analytics/__init__.py +15 -0
- studytimer_analytics-1.0.3/studytimer_analytics/aggregators.py +157 -0
- studytimer_analytics-1.0.3/studytimer_analytics/evaluators.py +81 -0
- studytimer_analytics-1.0.3/studytimer_analytics/formatters.py +79 -0
- studytimer_analytics-1.0.3/studytimer_analytics/timers.py +81 -0
- {studytimer_analytics-1.0.0 → studytimer_analytics-1.0.3}/studytimer_analytics.egg-info/PKG-INFO +1 -11
- studytimer_analytics-1.0.3/studytimer_analytics.egg-info/SOURCES.txt +12 -0
- studytimer_analytics-1.0.3/studytimer_analytics.egg-info/top_level.txt +1 -0
- studytimer_analytics-1.0.3/tests/test_analytics.py +188 -0
- studytimer_analytics-1.0.0/README.md +0 -12
- studytimer_analytics-1.0.0/studytimer_analytics.egg-info/SOURCES.txt +0 -6
- studytimer_analytics-1.0.0/studytimer_analytics.egg-info/top_level.txt +0 -1
- {studytimer_analytics-1.0.0 → studytimer_analytics-1.0.3}/setup.cfg +0 -0
- {studytimer_analytics-1.0.0 → studytimer_analytics-1.0.3}/studytimer_analytics.egg-info/dependency_links.txt +0 -0
|
@@ -1,20 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: studytimer-analytics
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.3
|
|
4
4
|
Summary: OOP analytics library for StudySync
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.10
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
|
|
9
9
|
# studytimer-analytics
|
|
10
|
-
|
|
11
10
|
OOP analytics library for StudySync.
|
|
12
|
-
|
|
13
|
-
## Install
|
|
14
|
-
pip install studytimer-analytics
|
|
15
|
-
|
|
16
|
-
## Classes
|
|
17
|
-
- SessionTimer
|
|
18
|
-
- ReportAggregator
|
|
19
|
-
- DurationFormatter
|
|
20
|
-
- StudyGoalEvaluator
|
|
@@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "studytimer-analytics"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.3"
|
|
8
8
|
description = "OOP analytics library for StudySync"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = { text = "MIT" }
|
|
12
12
|
|
|
13
13
|
[tool.setuptools.packages.find]
|
|
14
|
-
where = ["."]
|
|
14
|
+
where = ["."]
|
|
15
|
+
include = ["studytimer_analytics*"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
studytimer_analytics — Custom pip library for StudySync
|
|
3
|
+
Provides OOP analytics classes: SessionTimer, ReportAggregator,
|
|
4
|
+
DurationFormatter, StudyGoalEvaluator.
|
|
5
|
+
NFR-09: All classes follow OOP / single-responsibility principles with docstrings.
|
|
6
|
+
NFR-10: All four classes have dedicated unit tests achieving ≥70% coverage.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .timers import SessionTimer
|
|
10
|
+
from .aggregators import ReportAggregator
|
|
11
|
+
from .formatters import DurationFormatter
|
|
12
|
+
from .evaluators import StudyGoalEvaluator
|
|
13
|
+
|
|
14
|
+
__all__ = ['SessionTimer', 'ReportAggregator', 'DurationFormatter', 'StudyGoalEvaluator']
|
|
15
|
+
__version__ = '1.0.0'
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
studytimer_analytics.aggregators
|
|
3
|
+
Provides the ReportAggregator class for computing daily and weekly study reports.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import date, timedelta
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from django.contrib.auth.models import User
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ReportAggregator:
|
|
15
|
+
"""
|
|
16
|
+
Aggregates study session data for daily and weekly reports.
|
|
17
|
+
|
|
18
|
+
Responsibilities:
|
|
19
|
+
- Compute total study time per day/week (FR-07, FR-08)
|
|
20
|
+
- Break down study time by subject
|
|
21
|
+
- Calculate streaks and sessions completed
|
|
22
|
+
- Enforce user-scoped queries (NFR-03)
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
aggregator = ReportAggregator(user)
|
|
26
|
+
daily = aggregator.daily_report(date.today())
|
|
27
|
+
weekly = aggregator.weekly_report(date.today())
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, user: 'User'):
|
|
31
|
+
"""
|
|
32
|
+
Initialise the ReportAggregator.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
user: The authenticated Django User. All queries are scoped to this user.
|
|
36
|
+
"""
|
|
37
|
+
self._user = user
|
|
38
|
+
|
|
39
|
+
def daily_report(self, report_date: date) -> dict:
|
|
40
|
+
"""
|
|
41
|
+
Generate a daily study report for the given date (FR-07).
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
report_date: The date to generate the report for.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Dict with keys:
|
|
48
|
+
- total_seconds (int)
|
|
49
|
+
- session_count (int)
|
|
50
|
+
- avg_session_seconds (int)
|
|
51
|
+
- subject_breakdown (dict: subject -> seconds)
|
|
52
|
+
"""
|
|
53
|
+
from timer.models import StudySession
|
|
54
|
+
sessions = StudySession.objects.filter(
|
|
55
|
+
user=self._user,
|
|
56
|
+
status=StudySession.STATUS_COMPLETED,
|
|
57
|
+
started_at__date=report_date,
|
|
58
|
+
)
|
|
59
|
+
return self._aggregate_sessions(sessions)
|
|
60
|
+
|
|
61
|
+
def weekly_report(self, end_date: date) -> dict:
|
|
62
|
+
"""
|
|
63
|
+
Generate a rolling 7-day study report ending on end_date (FR-08).
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
end_date: The last day of the 7-day window.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Dict with keys:
|
|
70
|
+
- total_seconds (int)
|
|
71
|
+
- session_count (int)
|
|
72
|
+
- avg_session_seconds (int)
|
|
73
|
+
- days_studied (int)
|
|
74
|
+
- current_streak (int)
|
|
75
|
+
- subject_breakdown (dict: subject -> seconds)
|
|
76
|
+
- daily_labels (list of date strings)
|
|
77
|
+
- daily_totals (list of seconds per day)
|
|
78
|
+
"""
|
|
79
|
+
from timer.models import StudySession
|
|
80
|
+
start_date = end_date - timedelta(days=6)
|
|
81
|
+
sessions = StudySession.objects.filter(
|
|
82
|
+
user=self._user,
|
|
83
|
+
status=StudySession.STATUS_COMPLETED,
|
|
84
|
+
started_at__date__gte=start_date,
|
|
85
|
+
started_at__date__lte=end_date,
|
|
86
|
+
)
|
|
87
|
+
base = self._aggregate_sessions(sessions)
|
|
88
|
+
|
|
89
|
+
# Daily breakdown for chart
|
|
90
|
+
daily_map = defaultdict(int)
|
|
91
|
+
for s in sessions:
|
|
92
|
+
day_key = s.started_at.date()
|
|
93
|
+
daily_map[day_key] += (s.duration_seconds or 0)
|
|
94
|
+
|
|
95
|
+
daily_labels = []
|
|
96
|
+
daily_totals = []
|
|
97
|
+
for i in range(7):
|
|
98
|
+
d = start_date + timedelta(days=i)
|
|
99
|
+
daily_labels.append(d.strftime('%a %d/%m'))
|
|
100
|
+
daily_totals.append(daily_map.get(d, 0))
|
|
101
|
+
|
|
102
|
+
days_studied = sum(1 for t in daily_totals if t > 0)
|
|
103
|
+
streak = self._calculate_streak(end_date)
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
**base,
|
|
107
|
+
'days_studied': days_studied,
|
|
108
|
+
'current_streak': streak,
|
|
109
|
+
'daily_labels': daily_labels,
|
|
110
|
+
'daily_totals': daily_totals,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
def _aggregate_sessions(self, sessions) -> dict:
|
|
114
|
+
"""Compute aggregate stats from a queryset of completed sessions."""
|
|
115
|
+
total_seconds = 0
|
|
116
|
+
subject_breakdown = defaultdict(int)
|
|
117
|
+
session_list = list(sessions)
|
|
118
|
+
|
|
119
|
+
for s in session_list:
|
|
120
|
+
dur = s.duration_seconds or 0
|
|
121
|
+
total_seconds += dur
|
|
122
|
+
subject_breakdown[s.subject or 'General'] += dur
|
|
123
|
+
|
|
124
|
+
count = len(session_list)
|
|
125
|
+
avg = (total_seconds // count) if count > 0 else 0
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
'total_seconds': total_seconds,
|
|
129
|
+
'session_count': count,
|
|
130
|
+
'avg_session_seconds': avg,
|
|
131
|
+
'subject_breakdown': dict(subject_breakdown),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
def _calculate_streak(self, from_date: date) -> int:
|
|
135
|
+
"""
|
|
136
|
+
Calculate the current consecutive days studied streak.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
from_date: The most recent date to check backwards from.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Number of consecutive days with at least one completed session.
|
|
143
|
+
"""
|
|
144
|
+
from timer.models import StudySession
|
|
145
|
+
streak = 0
|
|
146
|
+
check_date = from_date
|
|
147
|
+
while True:
|
|
148
|
+
has_session = StudySession.objects.filter(
|
|
149
|
+
user=self._user,
|
|
150
|
+
status=StudySession.STATUS_COMPLETED,
|
|
151
|
+
started_at__date=check_date,
|
|
152
|
+
).exists()
|
|
153
|
+
if not has_session:
|
|
154
|
+
break
|
|
155
|
+
streak += 1
|
|
156
|
+
check_date -= timedelta(days=1)
|
|
157
|
+
return streak
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
studytimer_analytics.evaluators
|
|
3
|
+
Provides the StudyGoalEvaluator class for evaluating study goal achievement.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class StudyGoalEvaluator:
|
|
8
|
+
"""
|
|
9
|
+
Evaluates whether a student has achieved their study goals.
|
|
10
|
+
|
|
11
|
+
Responsibilities:
|
|
12
|
+
- Determine if a daily or weekly goal has been met (FR-11, FR-12)
|
|
13
|
+
- Calculate goal progress as a percentage
|
|
14
|
+
- Determine remaining time needed to reach goal
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
evaluator = StudyGoalEvaluator()
|
|
18
|
+
evaluator.is_goal_achieved(minutes_studied=130, goal_minutes=120) # -> True
|
|
19
|
+
evaluator.progress_percent(90, 120) # -> 75.0
|
|
20
|
+
evaluator.remaining_minutes(90, 120) # -> 30
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def is_goal_achieved(self, minutes_studied: int, goal_minutes: int) -> bool:
|
|
24
|
+
"""
|
|
25
|
+
Determine if the study goal has been achieved.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
minutes_studied: Total minutes studied in the period.
|
|
29
|
+
goal_minutes: The goal target in minutes.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
True if minutes_studied >= goal_minutes and goal_minutes > 0.
|
|
33
|
+
"""
|
|
34
|
+
if goal_minutes <= 0:
|
|
35
|
+
return False
|
|
36
|
+
return minutes_studied >= goal_minutes
|
|
37
|
+
|
|
38
|
+
def progress_percent(self, minutes_studied: int, goal_minutes: int) -> float:
|
|
39
|
+
"""
|
|
40
|
+
Calculate goal progress as a percentage (capped at 100%).
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
minutes_studied: Total minutes studied.
|
|
44
|
+
goal_minutes: The goal target in minutes.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Float between 0.0 and 100.0.
|
|
48
|
+
"""
|
|
49
|
+
if goal_minutes <= 0:
|
|
50
|
+
return 0.0
|
|
51
|
+
return min(100.0, round((minutes_studied / goal_minutes) * 100, 1))
|
|
52
|
+
|
|
53
|
+
def remaining_minutes(self, minutes_studied: int, goal_minutes: int) -> int:
|
|
54
|
+
"""
|
|
55
|
+
Calculate remaining minutes needed to reach the goal.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
minutes_studied: Total minutes studied.
|
|
59
|
+
goal_minutes: The goal target in minutes.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Remaining minutes (0 if goal already achieved).
|
|
63
|
+
"""
|
|
64
|
+
return max(0, goal_minutes - minutes_studied)
|
|
65
|
+
|
|
66
|
+
def evaluate(self, minutes_studied: int, goal_minutes: int) -> dict:
|
|
67
|
+
"""
|
|
68
|
+
Return a full goal evaluation summary dict.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
minutes_studied: Total minutes studied.
|
|
72
|
+
goal_minutes: The goal target in minutes.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Dict with keys: achieved, progress_percent, remaining_minutes.
|
|
76
|
+
"""
|
|
77
|
+
return {
|
|
78
|
+
'achieved': self.is_goal_achieved(minutes_studied, goal_minutes),
|
|
79
|
+
'progress_percent': self.progress_percent(minutes_studied, goal_minutes),
|
|
80
|
+
'remaining_minutes': self.remaining_minutes(minutes_studied, goal_minutes),
|
|
81
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
studytimer_analytics.formatters
|
|
3
|
+
Provides the DurationFormatter class for human-readable time formatting.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DurationFormatter:
|
|
8
|
+
"""
|
|
9
|
+
Formats duration values into human-readable strings.
|
|
10
|
+
|
|
11
|
+
Responsibilities:
|
|
12
|
+
- Convert seconds to HH:MM:SS
|
|
13
|
+
- Convert seconds to human-readable text (e.g. "1 hr 23 min")
|
|
14
|
+
- Format minutes into short display strings
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
fmt = DurationFormatter()
|
|
18
|
+
fmt.format_duration(3661) # -> "1 hr 1 min 1 sec"
|
|
19
|
+
fmt.format_hms(3661) # -> "01:01:01"
|
|
20
|
+
fmt.format_minutes(90) # -> "1 hr 30 min"
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def format_duration(self, total_seconds: int) -> str:
|
|
24
|
+
"""
|
|
25
|
+
Convert seconds into a human-readable duration string.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
total_seconds: Number of seconds to format.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
E.g. "2 hr 5 min 30 sec", "45 min 0 sec", "30 sec"
|
|
32
|
+
"""
|
|
33
|
+
total_seconds = max(0, int(total_seconds))
|
|
34
|
+
hours, remainder = divmod(total_seconds, 3600)
|
|
35
|
+
minutes, seconds = divmod(remainder, 60)
|
|
36
|
+
|
|
37
|
+
parts = []
|
|
38
|
+
if hours > 0:
|
|
39
|
+
parts.append(f"{hours} hr")
|
|
40
|
+
if minutes > 0 or hours > 0:
|
|
41
|
+
parts.append(f"{minutes} min")
|
|
42
|
+
parts.append(f"{seconds} sec")
|
|
43
|
+
|
|
44
|
+
return ' '.join(parts)
|
|
45
|
+
|
|
46
|
+
def format_hms(self, total_seconds: int) -> str:
|
|
47
|
+
"""
|
|
48
|
+
Format seconds as HH:MM:SS string.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
total_seconds: Number of seconds.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
String in HH:MM:SS format, e.g. '01:23:45'.
|
|
55
|
+
"""
|
|
56
|
+
total_seconds = max(0, int(total_seconds))
|
|
57
|
+
hours, remainder = divmod(total_seconds, 3600)
|
|
58
|
+
minutes, seconds = divmod(remainder, 60)
|
|
59
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
60
|
+
|
|
61
|
+
def format_minutes(self, total_minutes: int) -> str:
|
|
62
|
+
"""
|
|
63
|
+
Format total minutes as a short human-readable string.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
total_minutes: Number of minutes to format.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
E.g. "1 hr 30 min", "45 min", "0 min"
|
|
70
|
+
"""
|
|
71
|
+
total_minutes = max(0, int(total_minutes))
|
|
72
|
+
hours, minutes = divmod(total_minutes, 60)
|
|
73
|
+
if hours > 0:
|
|
74
|
+
return f"{hours} hr {minutes} min"
|
|
75
|
+
return f"{minutes} min"
|
|
76
|
+
|
|
77
|
+
def seconds_to_minutes(self, seconds: int) -> float:
|
|
78
|
+
"""Convert seconds to fractional minutes."""
|
|
79
|
+
return max(0, seconds) / 60.0
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
studytimer_analytics.timers
|
|
3
|
+
Provides the SessionTimer class for calculating elapsed time and session state.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import datetime, timezone as dt_timezone
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SessionTimer:
|
|
11
|
+
"""
|
|
12
|
+
Calculates elapsed time and session state for a study session.
|
|
13
|
+
|
|
14
|
+
Responsibilities:
|
|
15
|
+
- Track start/end timestamps
|
|
16
|
+
- Calculate elapsed seconds accurately (NFR-05: ±1 second accuracy)
|
|
17
|
+
- Determine if a session is active or completed
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
timer = SessionTimer(started_at=session.started_at)
|
|
21
|
+
elapsed = timer.elapsed_seconds()
|
|
22
|
+
hms = timer.format_hms()
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, started_at: datetime, ended_at: Optional[datetime] = None):
|
|
26
|
+
"""
|
|
27
|
+
Initialise the SessionTimer.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
started_at: The UTC datetime when the session started.
|
|
31
|
+
ended_at: The UTC datetime when the session ended (None if active).
|
|
32
|
+
"""
|
|
33
|
+
self._started_at = self._ensure_utc(started_at)
|
|
34
|
+
self._ended_at = self._ensure_utc(ended_at) if ended_at else None
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def _ensure_utc(dt: datetime) -> datetime:
|
|
38
|
+
"""Ensure datetime is timezone-aware (UTC)."""
|
|
39
|
+
if dt.tzinfo is None:
|
|
40
|
+
return dt.replace(tzinfo=dt_timezone.utc)
|
|
41
|
+
return dt
|
|
42
|
+
|
|
43
|
+
def is_active(self) -> bool:
|
|
44
|
+
"""Return True if the session is still running."""
|
|
45
|
+
return self._ended_at is None
|
|
46
|
+
|
|
47
|
+
def elapsed_seconds(self) -> int:
|
|
48
|
+
"""
|
|
49
|
+
Calculate total elapsed seconds.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Elapsed seconds as an integer. If session is active,
|
|
53
|
+
calculates from now. If completed, uses ended_at.
|
|
54
|
+
"""
|
|
55
|
+
end = self._ended_at if self._ended_at else datetime.now(dt_timezone.utc)
|
|
56
|
+
delta = end - self._started_at
|
|
57
|
+
return max(0, int(delta.total_seconds()))
|
|
58
|
+
|
|
59
|
+
def elapsed_minutes(self) -> float:
|
|
60
|
+
"""Return elapsed time in fractional minutes."""
|
|
61
|
+
return self.elapsed_seconds() / 60.0
|
|
62
|
+
|
|
63
|
+
def format_hms(self) -> str:
|
|
64
|
+
"""
|
|
65
|
+
Format elapsed time as HH:MM:SS string (FR-05).
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
String in HH:MM:SS format, e.g. '01:23:45'.
|
|
69
|
+
"""
|
|
70
|
+
total = self.elapsed_seconds()
|
|
71
|
+
hours, remainder = divmod(total, 3600)
|
|
72
|
+
minutes, seconds = divmod(remainder, 60)
|
|
73
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
74
|
+
|
|
75
|
+
def started_at(self) -> datetime:
|
|
76
|
+
"""Return the session start datetime."""
|
|
77
|
+
return self._started_at
|
|
78
|
+
|
|
79
|
+
def ended_at(self) -> Optional[datetime]:
|
|
80
|
+
"""Return the session end datetime, or None if still active."""
|
|
81
|
+
return self._ended_at
|
{studytimer_analytics-1.0.0 → studytimer_analytics-1.0.3}/studytimer_analytics.egg-info/PKG-INFO
RENAMED
|
@@ -1,20 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: studytimer-analytics
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.3
|
|
4
4
|
Summary: OOP analytics library for StudySync
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.10
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
|
|
9
9
|
# studytimer-analytics
|
|
10
|
-
|
|
11
10
|
OOP analytics library for StudySync.
|
|
12
|
-
|
|
13
|
-
## Install
|
|
14
|
-
pip install studytimer-analytics
|
|
15
|
-
|
|
16
|
-
## Classes
|
|
17
|
-
- SessionTimer
|
|
18
|
-
- ReportAggregator
|
|
19
|
-
- DurationFormatter
|
|
20
|
-
- StudyGoalEvaluator
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
studytimer_analytics/__init__.py
|
|
4
|
+
studytimer_analytics/aggregators.py
|
|
5
|
+
studytimer_analytics/evaluators.py
|
|
6
|
+
studytimer_analytics/formatters.py
|
|
7
|
+
studytimer_analytics/timers.py
|
|
8
|
+
studytimer_analytics.egg-info/PKG-INFO
|
|
9
|
+
studytimer_analytics.egg-info/SOURCES.txt
|
|
10
|
+
studytimer_analytics.egg-info/dependency_links.txt
|
|
11
|
+
studytimer_analytics.egg-info/top_level.txt
|
|
12
|
+
tests/test_analytics.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
studytimer_analytics
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
StudySync — Unit Tests for studytimer_analytics library
|
|
3
|
+
NFR-10: All four OOP classes tested with ≥70% code coverage.
|
|
4
|
+
Run: pytest tests/ -v --cov=studytimer_analytics --cov-report=term-missing
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import os
|
|
9
|
+
import pytest
|
|
10
|
+
from datetime import datetime, timezone as dt_tz, timedelta
|
|
11
|
+
|
|
12
|
+
# Add project root to path
|
|
13
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
14
|
+
|
|
15
|
+
from studytimer_analytics.timers import SessionTimer
|
|
16
|
+
from studytimer_analytics.formatters import DurationFormatter
|
|
17
|
+
from studytimer_analytics.evaluators import StudyGoalEvaluator
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── SessionTimer Tests ────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
class TestSessionTimer:
|
|
23
|
+
"""Unit tests for SessionTimer (NFR-10)."""
|
|
24
|
+
|
|
25
|
+
def test_active_session_elapsed_is_positive(self):
|
|
26
|
+
started = datetime.now(dt_tz.utc) - timedelta(minutes=5)
|
|
27
|
+
timer = SessionTimer(started_at=started)
|
|
28
|
+
assert timer.elapsed_seconds() >= 300
|
|
29
|
+
|
|
30
|
+
def test_completed_session_exact_duration(self):
|
|
31
|
+
started = datetime(2025, 1, 1, 10, 0, 0, tzinfo=dt_tz.utc)
|
|
32
|
+
ended = datetime(2025, 1, 1, 11, 30, 0, tzinfo=dt_tz.utc)
|
|
33
|
+
timer = SessionTimer(started_at=started, ended_at=ended)
|
|
34
|
+
assert timer.elapsed_seconds() == 5400 # 90 minutes
|
|
35
|
+
|
|
36
|
+
def test_is_active_true_when_no_end(self):
|
|
37
|
+
timer = SessionTimer(started_at=datetime.now(dt_tz.utc))
|
|
38
|
+
assert timer.is_active() is True
|
|
39
|
+
|
|
40
|
+
def test_is_active_false_when_ended(self):
|
|
41
|
+
started = datetime(2025, 1, 1, 9, 0, 0, tzinfo=dt_tz.utc)
|
|
42
|
+
ended = datetime(2025, 1, 1, 10, 0, 0, tzinfo=dt_tz.utc)
|
|
43
|
+
timer = SessionTimer(started_at=started, ended_at=ended)
|
|
44
|
+
assert timer.is_active() is False
|
|
45
|
+
|
|
46
|
+
def test_format_hms_zero(self):
|
|
47
|
+
started = datetime.now(dt_tz.utc)
|
|
48
|
+
ended = started
|
|
49
|
+
timer = SessionTimer(started_at=started, ended_at=ended)
|
|
50
|
+
assert timer.format_hms() == "00:00:00"
|
|
51
|
+
|
|
52
|
+
def test_format_hms_one_hour(self):
|
|
53
|
+
started = datetime(2025, 1, 1, 10, 0, 0, tzinfo=dt_tz.utc)
|
|
54
|
+
ended = datetime(2025, 1, 1, 11, 0, 0, tzinfo=dt_tz.utc)
|
|
55
|
+
timer = SessionTimer(started_at=started, ended_at=ended)
|
|
56
|
+
assert timer.format_hms() == "01:00:00"
|
|
57
|
+
|
|
58
|
+
def test_format_hms_complex(self):
|
|
59
|
+
started = datetime(2025, 1, 1, 10, 0, 0, tzinfo=dt_tz.utc)
|
|
60
|
+
ended = datetime(2025, 1, 1, 11, 23, 45, tzinfo=dt_tz.utc)
|
|
61
|
+
timer = SessionTimer(started_at=started, ended_at=ended)
|
|
62
|
+
assert timer.format_hms() == "01:23:45"
|
|
63
|
+
|
|
64
|
+
def test_elapsed_minutes(self):
|
|
65
|
+
started = datetime(2025, 1, 1, 10, 0, 0, tzinfo=dt_tz.utc)
|
|
66
|
+
ended = datetime(2025, 1, 1, 10, 30, 0, tzinfo=dt_tz.utc)
|
|
67
|
+
timer = SessionTimer(started_at=started, ended_at=ended)
|
|
68
|
+
assert timer.elapsed_minutes() == 30.0
|
|
69
|
+
|
|
70
|
+
def test_naive_datetime_handled(self):
|
|
71
|
+
"""SessionTimer should handle naive datetimes by assuming UTC."""
|
|
72
|
+
started = datetime(2025, 1, 1, 10, 0, 0) # naive
|
|
73
|
+
ended = datetime(2025, 1, 1, 10, 1, 0) # naive
|
|
74
|
+
timer = SessionTimer(started_at=started, ended_at=ended)
|
|
75
|
+
assert timer.elapsed_seconds() == 60
|
|
76
|
+
|
|
77
|
+
def test_elapsed_never_negative(self):
|
|
78
|
+
# If end is before start (data error), should return 0
|
|
79
|
+
started = datetime(2025, 1, 1, 11, 0, 0, tzinfo=dt_tz.utc)
|
|
80
|
+
ended = datetime(2025, 1, 1, 10, 0, 0, tzinfo=dt_tz.utc)
|
|
81
|
+
timer = SessionTimer(started_at=started, ended_at=ended)
|
|
82
|
+
assert timer.elapsed_seconds() == 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── DurationFormatter Tests ───────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
class TestDurationFormatter:
|
|
88
|
+
"""Unit tests for DurationFormatter (NFR-10)."""
|
|
89
|
+
|
|
90
|
+
def setup_method(self):
|
|
91
|
+
self.fmt = DurationFormatter()
|
|
92
|
+
|
|
93
|
+
def test_format_duration_zero(self):
|
|
94
|
+
assert self.fmt.format_duration(0) == "0 sec"
|
|
95
|
+
|
|
96
|
+
def test_format_duration_seconds_only(self):
|
|
97
|
+
assert self.fmt.format_duration(45) == "45 sec"
|
|
98
|
+
|
|
99
|
+
def test_format_duration_minutes(self):
|
|
100
|
+
assert self.fmt.format_duration(90) == "1 min 30 sec"
|
|
101
|
+
|
|
102
|
+
def test_format_duration_hours(self):
|
|
103
|
+
assert self.fmt.format_duration(3661) == "1 hr 1 min 1 sec"
|
|
104
|
+
|
|
105
|
+
def test_format_duration_exact_hour(self):
|
|
106
|
+
assert self.fmt.format_duration(3600) == "1 hr 0 min 0 sec"
|
|
107
|
+
|
|
108
|
+
def test_format_hms_zero(self):
|
|
109
|
+
assert self.fmt.format_hms(0) == "00:00:00"
|
|
110
|
+
|
|
111
|
+
def test_format_hms_full(self):
|
|
112
|
+
assert self.fmt.format_hms(3661) == "01:01:01"
|
|
113
|
+
|
|
114
|
+
def test_format_hms_large(self):
|
|
115
|
+
assert self.fmt.format_hms(36000) == "10:00:00"
|
|
116
|
+
|
|
117
|
+
def test_format_minutes_under_hour(self):
|
|
118
|
+
assert self.fmt.format_minutes(45) == "45 min"
|
|
119
|
+
|
|
120
|
+
def test_format_minutes_over_hour(self):
|
|
121
|
+
assert self.fmt.format_minutes(90) == "1 hr 30 min"
|
|
122
|
+
|
|
123
|
+
def test_format_minutes_zero(self):
|
|
124
|
+
assert self.fmt.format_minutes(0) == "0 min"
|
|
125
|
+
|
|
126
|
+
def test_seconds_to_minutes(self):
|
|
127
|
+
assert self.fmt.seconds_to_minutes(120) == 2.0
|
|
128
|
+
|
|
129
|
+
def test_format_duration_negative_treated_as_zero(self):
|
|
130
|
+
assert self.fmt.format_duration(-10) == "0 sec"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ── StudyGoalEvaluator Tests ──────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
class TestStudyGoalEvaluator:
|
|
136
|
+
"""Unit tests for StudyGoalEvaluator (NFR-10)."""
|
|
137
|
+
|
|
138
|
+
def setup_method(self):
|
|
139
|
+
self.ev = StudyGoalEvaluator()
|
|
140
|
+
|
|
141
|
+
def test_goal_achieved_exact(self):
|
|
142
|
+
assert self.ev.is_goal_achieved(120, 120) is True
|
|
143
|
+
|
|
144
|
+
def test_goal_achieved_over(self):
|
|
145
|
+
assert self.ev.is_goal_achieved(150, 120) is True
|
|
146
|
+
|
|
147
|
+
def test_goal_not_achieved(self):
|
|
148
|
+
assert self.ev.is_goal_achieved(90, 120) is False
|
|
149
|
+
|
|
150
|
+
def test_goal_zero_target(self):
|
|
151
|
+
assert self.ev.is_goal_achieved(50, 0) is False
|
|
152
|
+
|
|
153
|
+
def test_progress_percent_half(self):
|
|
154
|
+
assert self.ev.progress_percent(60, 120) == 50.0
|
|
155
|
+
|
|
156
|
+
def test_progress_percent_over_100(self):
|
|
157
|
+
assert self.ev.progress_percent(200, 100) == 100.0
|
|
158
|
+
|
|
159
|
+
def test_progress_percent_zero_goal(self):
|
|
160
|
+
assert self.ev.progress_percent(50, 0) == 0.0
|
|
161
|
+
|
|
162
|
+
def test_remaining_minutes(self):
|
|
163
|
+
assert self.ev.remaining_minutes(90, 120) == 30
|
|
164
|
+
|
|
165
|
+
def test_remaining_minutes_achieved(self):
|
|
166
|
+
assert self.ev.remaining_minutes(130, 120) == 0
|
|
167
|
+
|
|
168
|
+
def test_evaluate_returns_dict(self):
|
|
169
|
+
result = self.ev.evaluate(100, 120)
|
|
170
|
+
assert isinstance(result, dict)
|
|
171
|
+
assert 'achieved' in result
|
|
172
|
+
assert 'progress_percent' in result
|
|
173
|
+
assert 'remaining_minutes' in result
|
|
174
|
+
|
|
175
|
+
def test_evaluate_not_achieved(self):
|
|
176
|
+
result = self.ev.evaluate(60, 120)
|
|
177
|
+
assert result['achieved'] is False
|
|
178
|
+
assert result['progress_percent'] == 50.0
|
|
179
|
+
assert result['remaining_minutes'] == 60
|
|
180
|
+
|
|
181
|
+
def test_evaluate_achieved(self):
|
|
182
|
+
result = self.ev.evaluate(120, 120)
|
|
183
|
+
assert result['achieved'] is True
|
|
184
|
+
assert result['remaining_minutes'] == 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == '__main__':
|
|
188
|
+
pytest.main([__file__, '-v'])
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
dist
|
|
File without changes
|
|
File without changes
|