stanwell 0.1.0__py3-none-any.whl

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.
Files changed (43) hide show
  1. stanwell/__init__.py +1 -0
  2. stanwell/analysis/__init__.py +1 -0
  3. stanwell/analysis/projections.py +167 -0
  4. stanwell/analysis/scoring.py +231 -0
  5. stanwell/analysis/tag_analysis.py +579 -0
  6. stanwell/analysis/working_patterns.py +369 -0
  7. stanwell/analysis/working_time.py +496 -0
  8. stanwell/configuration.py +107 -0
  9. stanwell/data_repository/__init__.py +1 -0
  10. stanwell/data_repository/data_coverage.py +290 -0
  11. stanwell/data_repository/data_util.py +45 -0
  12. stanwell/data_repository/database_schema.py +172 -0
  13. stanwell/data_repository/db.py +89 -0
  14. stanwell/data_repository/projects.py +127 -0
  15. stanwell/data_repository/time_entries.py +183 -0
  16. stanwell/py.typed +0 -0
  17. stanwell/toggl/__init__.py +1 -0
  18. stanwell/toggl/client.py +642 -0
  19. stanwell/toggl/constants.py +12 -0
  20. stanwell/toggl/exceptions.py +60 -0
  21. stanwell/toggl/normalisation.py +219 -0
  22. stanwell/toggl/sync.py +969 -0
  23. stanwell/ui/__init__.py +1 -0
  24. stanwell/ui/cli/__init__.py +1 -0
  25. stanwell/ui/cli/cli.py +680 -0
  26. stanwell/ui/cli/cli_util.py +61 -0
  27. stanwell/ui/dashboard/__init__.py +1 -0
  28. stanwell/ui/dashboard/constants.py +21 -0
  29. stanwell/ui/dashboard/context.py +223 -0
  30. stanwell/ui/dashboard/dashboard.py +94 -0
  31. stanwell/ui/dashboard/helpers.py +90 -0
  32. stanwell/ui/dashboard/sidebar.py +449 -0
  33. stanwell/ui/dashboard/tabs/__init__.py +1 -0
  34. stanwell/ui/dashboard/tabs/overview.py +868 -0
  35. stanwell/ui/dashboard/tabs/projects.py +273 -0
  36. stanwell/ui/dashboard/tabs/tag_balances.py +779 -0
  37. stanwell/ui/dashboard/tabs/work_patterns.py +960 -0
  38. stanwell/ui/date_periods.py +86 -0
  39. stanwell-0.1.0.dist-info/METADATA +17 -0
  40. stanwell-0.1.0.dist-info/RECORD +43 -0
  41. stanwell-0.1.0.dist-info/WHEEL +4 -0
  42. stanwell-0.1.0.dist-info/entry_points.txt +2 -0
  43. stanwell-0.1.0.dist-info/licenses/LICENSE +21 -0
stanwell/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Stanwell - Healthy Work Time Tracking."""
@@ -0,0 +1 @@
1
+ """Analysis of work time and patterns."""
@@ -0,0 +1,167 @@
1
+ """Projection calculations for the working-time dashboard."""
2
+
3
+ from datetime import date, timedelta
4
+
5
+ import pandas as pd
6
+
7
+
8
+ def prepare_week_remaining_projection(
9
+ daily: pd.DataFrame,
10
+ today: date,
11
+ ) -> pd.DataFrame:
12
+ """Calculate required work for the remaining days of the week.
13
+
14
+ Calculate the outstanding working-time requirement for the week
15
+ containing ``today`` and distribute it equally across the remaining
16
+ working days. The calculation assumes that no further work is done
17
+ on ``today``.
18
+
19
+ Days with zero expected hours, such as weekends, annual leave,
20
+ public holidays or closure days, are excluded from the remaining
21
+ working days. If the weekly requirement has already been met or
22
+ exceeded, each remaining working day is assigned zero required
23
+ hours.
24
+
25
+ Parameters
26
+ ----------
27
+ daily : pandas.DataFrame
28
+ Daily working-time data. The DataFrame must contain the columns
29
+ ``day``, ``weekday``, ``expected_hours`` and ``actual_hours``.
30
+ It may also contain ``balance_adjustment_hours``.
31
+ today : datetime.date
32
+ Date from which the remaining part of the current week is
33
+ calculated. Work recorded on this date contributes to the
34
+ completed weekly hours, but no further work on this date is
35
+ assumed.
36
+
37
+ Returns
38
+ -------
39
+ pandas.DataFrame
40
+ A DataFrame containing one row for each remaining working day
41
+ in the current Monday-to-Sunday week. It contains the columns
42
+ ``day``, ``weekday`` and ``required_hours``. The remaining
43
+ requirement is distributed equally across these days.
44
+
45
+ An empty DataFrame with these columns is returned if ``daily``
46
+ is empty, contains no data for the current week or there are no
47
+ remaining working days.
48
+
49
+ Notes
50
+ -----
51
+ A remaining day is considered a working day only when its
52
+ ``expected_hours`` value is greater than zero. Consequently, the
53
+ function does not need to identify annual leave, public holidays
54
+ or closure days directly; these are expected to have already been
55
+ reflected in the daily working-time calculation.
56
+
57
+ The outstanding requirement is bounded at zero, so working more
58
+ than the weekly requirement never produces negative required hours
59
+ for subsequent days.
60
+
61
+ Examples
62
+ --------
63
+ If the normal requirement is 7 hours per weekday and 22 hours have
64
+ been worked by the end of Wednesday, the weekly requirement is
65
+ 35 hours. The remaining 13 hours are distributed across Thursday
66
+ and Friday, resulting in 6.5 required hours on each day.
67
+ """
68
+ if daily.empty:
69
+ return pd.DataFrame(
70
+ columns=[
71
+ "day",
72
+ "weekday",
73
+ "required_hours",
74
+ ]
75
+ )
76
+
77
+ week_start = today - timedelta(days=today.weekday())
78
+
79
+ week_end = week_start + timedelta(days=6)
80
+
81
+ weekly = daily.copy()
82
+
83
+ weekly["day"] = pd.to_datetime(weekly["day"]).dt.date
84
+
85
+ weekly = weekly[(weekly["day"] >= week_start) & (weekly["day"] <= week_end)].copy()
86
+
87
+ if weekly.empty:
88
+ return pd.DataFrame(
89
+ columns=[
90
+ "day",
91
+ "weekday",
92
+ "required_hours",
93
+ ]
94
+ )
95
+
96
+ total_expected = (
97
+ pd.to_numeric(
98
+ weekly["expected_hours"],
99
+ errors="coerce",
100
+ )
101
+ .fillna(0)
102
+ .sum()
103
+ )
104
+
105
+ actual_so_far = (
106
+ pd.to_numeric(
107
+ weekly.loc[
108
+ weekly["day"] <= today,
109
+ "actual_hours",
110
+ ],
111
+ errors="coerce",
112
+ )
113
+ .fillna(0)
114
+ .sum()
115
+ )
116
+
117
+ adjustment_so_far = 0.0
118
+
119
+ if "balance_adjustment_hours" in weekly.columns:
120
+ adjustment_so_far = (
121
+ pd.to_numeric(
122
+ weekly.loc[
123
+ weekly["day"] <= today,
124
+ "balance_adjustment_hours",
125
+ ],
126
+ errors="coerce",
127
+ )
128
+ .fillna(0)
129
+ .sum()
130
+ )
131
+
132
+ remaining_hours = max(
133
+ 0.0,
134
+ total_expected - actual_so_far - adjustment_so_far,
135
+ )
136
+
137
+ remaining_days = weekly[
138
+ (weekly["day"] > today)
139
+ & (
140
+ pd.to_numeric(
141
+ weekly["expected_hours"],
142
+ errors="coerce",
143
+ ).fillna(0)
144
+ > 0
145
+ )
146
+ ].copy()
147
+
148
+ if remaining_days.empty:
149
+ return pd.DataFrame(
150
+ columns=[
151
+ "day",
152
+ "weekday",
153
+ "required_hours",
154
+ ]
155
+ )
156
+
157
+ required_per_day = remaining_hours / len(remaining_days)
158
+
159
+ remaining_days["required_hours"] = required_per_day
160
+
161
+ return remaining_days[
162
+ [
163
+ "day",
164
+ "weekday",
165
+ "required_hours",
166
+ ]
167
+ ]
@@ -0,0 +1,231 @@
1
+ """Calculate smoothed working-pattern and tag-based quality scores."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pandas as pd
6
+
7
+ WORKING_SCORE_COLUMNS = [
8
+ "weekend_quality",
9
+ "core_hours_quality",
10
+ "day_off_quality",
11
+ "overall_quality",
12
+ "weekend_score",
13
+ "core_hours_score",
14
+ "day_off_score",
15
+ "overall_score",
16
+ ]
17
+
18
+
19
+ def _calculate_retention(
20
+ half_life_weeks: float,
21
+ ) -> float:
22
+ """Calculate exponential retention from a half-life.
23
+
24
+ Parameters
25
+ ----------
26
+ half_life_weeks : float
27
+ Number of weeks over which historical influence halves.
28
+
29
+ Returns
30
+ -------
31
+ float
32
+ Retention applied to the previous score.
33
+
34
+ Raises
35
+ ------
36
+ ValueError
37
+ If ``half_life_weeks`` is not greater than zero.
38
+
39
+ """
40
+ if half_life_weeks <= 0:
41
+ raise ValueError("half_life_weeks must be greater than 0")
42
+
43
+ retention = 0.5 ** (1.0 / half_life_weeks)
44
+
45
+ return retention
46
+
47
+
48
+ def _smooth_values(
49
+ weekly: pd.DataFrame,
50
+ quality_column: str,
51
+ half_life_weeks: float,
52
+ minimum_week_hours: float,
53
+ ) -> list[float | None]:
54
+ """Smooth weekly quality values using exponential retention."""
55
+ retention = _calculate_retention(half_life_weeks)
56
+ current_weight = 1.0 - retention
57
+
58
+ scores: list[float | None] = []
59
+ previous_score: float | None = None
60
+
61
+ for weekly_hours, current_quality in zip(
62
+ weekly["total_hours"].astype(float),
63
+ weekly[quality_column].astype(float),
64
+ strict=True,
65
+ ):
66
+ if weekly_hours >= minimum_week_hours:
67
+ if previous_score is None:
68
+ new_score = current_quality
69
+ else:
70
+ new_score = (
71
+ previous_score * retention + current_quality * current_weight
72
+ )
73
+
74
+ new_score = max(
75
+ 0.0,
76
+ min(100.0, new_score),
77
+ )
78
+
79
+ previous_score = new_score
80
+ scores.append(new_score)
81
+ else:
82
+ scores.append(previous_score)
83
+
84
+ return scores
85
+
86
+
87
+ def add_smoothed_working_scores(
88
+ weekly: pd.DataFrame,
89
+ half_life_weeks: float = 6.0,
90
+ minimum_week_hours: float = 1.0,
91
+ ) -> pd.DataFrame:
92
+ """Add raw and smoothed working-pattern quality scores.
93
+
94
+ Quality and score values range from 0 to 100. Higher values indicate
95
+ that a smaller proportion of recorded time fell into the relevant
96
+ exceptional working category.
97
+
98
+ Weeks below ``minimum_week_hours`` do not update the smoothed score.
99
+
100
+ Parameters
101
+ ----------
102
+ weekly : pandas.DataFrame
103
+ Weekly working-pattern totals.
104
+ half_life_weeks : float, default=6.0
105
+ Half-life used for exponential score smoothing.
106
+ minimum_week_hours : float, default=1.0
107
+ Minimum recorded hours required to update a score.
108
+
109
+ Returns
110
+ -------
111
+ pandas.DataFrame
112
+ Copy of ``weekly`` containing raw quality values and smoothed
113
+ scores.
114
+
115
+ """
116
+ result = weekly.copy()
117
+
118
+ if result.empty:
119
+ for column in WORKING_SCORE_COLUMNS:
120
+ result[column] = pd.Series(dtype=float)
121
+ else:
122
+ result = result.sort_values("week_start").reset_index(drop=True)
123
+
124
+ total_hours = result["total_hours"].replace(
125
+ 0,
126
+ pd.NA,
127
+ )
128
+
129
+ result["weekend_share"] = (result["weekend_hours"] / total_hours).fillna(0.0)
130
+
131
+ result["outside_core_share"] = (
132
+ result["outside_core_hours"] / total_hours
133
+ ).fillna(0.0)
134
+
135
+ result["non_working_day_share"] = (
136
+ result["non_working_day_hours"] / total_hours
137
+ ).fillna(0.0)
138
+
139
+ result["undesirable_share"] = (
140
+ result["weekend_share"]
141
+ + result["outside_core_share"]
142
+ + result["non_working_day_share"]
143
+ ).clip(
144
+ lower=0.0,
145
+ upper=1.0,
146
+ )
147
+
148
+ result["weekend_quality"] = (100.0 * (1.0 - result["weekend_share"])).clip(
149
+ 0.0, 100.0
150
+ )
151
+
152
+ result["core_hours_quality"] = (
153
+ 100.0 * (1.0 - result["outside_core_share"])
154
+ ).clip(0.0, 100.0)
155
+
156
+ result["day_off_quality"] = (
157
+ 100.0 * (1.0 - result["non_working_day_share"])
158
+ ).clip(0.0, 100.0)
159
+
160
+ result["overall_quality"] = (100.0 * (1.0 - result["undesirable_share"])).clip(
161
+ 0.0, 100.0
162
+ )
163
+
164
+ result["weekend_score"] = _smooth_values(
165
+ weekly=result,
166
+ quality_column="weekend_quality",
167
+ half_life_weeks=half_life_weeks,
168
+ minimum_week_hours=minimum_week_hours,
169
+ )
170
+ result["core_hours_score"] = _smooth_values(
171
+ weekly=result,
172
+ quality_column="core_hours_quality",
173
+ half_life_weeks=half_life_weeks,
174
+ minimum_week_hours=minimum_week_hours,
175
+ )
176
+ result["day_off_score"] = _smooth_values(
177
+ weekly=result,
178
+ quality_column="day_off_quality",
179
+ half_life_weeks=half_life_weeks,
180
+ minimum_week_hours=minimum_week_hours,
181
+ )
182
+ result["overall_score"] = _smooth_values(
183
+ weekly=result,
184
+ quality_column="overall_quality",
185
+ half_life_weeks=half_life_weeks,
186
+ minimum_week_hours=minimum_week_hours,
187
+ )
188
+
189
+ return result
190
+
191
+
192
+ def add_smoothed_tag_score(
193
+ weekly: pd.DataFrame,
194
+ half_life_weeks: float = 6.0,
195
+ minimum_week_hours: float = 1.0,
196
+ ) -> pd.DataFrame:
197
+ """Add an exponentially smoothed tag-quality score.
198
+
199
+ Weeks below ``minimum_week_hours`` retain the preceding score rather
200
+ than updating it from potentially incomplete data.
201
+
202
+ Parameters
203
+ ----------
204
+ weekly : pandas.DataFrame
205
+ Weekly tag-quality values.
206
+ half_life_weeks : float, default=6.0
207
+ Half-life used for exponential smoothing.
208
+ minimum_week_hours : float, default=1.0
209
+ Minimum recorded hours required to update the score.
210
+
211
+ Returns
212
+ -------
213
+ pandas.DataFrame
214
+ Copy of ``weekly`` containing ``smoothed_score``.
215
+
216
+ """
217
+ result = weekly.copy()
218
+
219
+ if result.empty:
220
+ result["smoothed_score"] = pd.Series(dtype=float)
221
+ else:
222
+ result = result.sort_values("week_start").reset_index(drop=True)
223
+
224
+ result["smoothed_score"] = _smooth_values(
225
+ weekly=result,
226
+ quality_column="weekly_quality",
227
+ half_life_weeks=half_life_weeks,
228
+ minimum_week_hours=minimum_week_hours,
229
+ )
230
+
231
+ return result