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.
- stanwell/__init__.py +1 -0
- stanwell/analysis/__init__.py +1 -0
- stanwell/analysis/projections.py +167 -0
- stanwell/analysis/scoring.py +231 -0
- stanwell/analysis/tag_analysis.py +579 -0
- stanwell/analysis/working_patterns.py +369 -0
- stanwell/analysis/working_time.py +496 -0
- stanwell/configuration.py +107 -0
- stanwell/data_repository/__init__.py +1 -0
- stanwell/data_repository/data_coverage.py +290 -0
- stanwell/data_repository/data_util.py +45 -0
- stanwell/data_repository/database_schema.py +172 -0
- stanwell/data_repository/db.py +89 -0
- stanwell/data_repository/projects.py +127 -0
- stanwell/data_repository/time_entries.py +183 -0
- stanwell/py.typed +0 -0
- stanwell/toggl/__init__.py +1 -0
- stanwell/toggl/client.py +642 -0
- stanwell/toggl/constants.py +12 -0
- stanwell/toggl/exceptions.py +60 -0
- stanwell/toggl/normalisation.py +219 -0
- stanwell/toggl/sync.py +969 -0
- stanwell/ui/__init__.py +1 -0
- stanwell/ui/cli/__init__.py +1 -0
- stanwell/ui/cli/cli.py +680 -0
- stanwell/ui/cli/cli_util.py +61 -0
- stanwell/ui/dashboard/__init__.py +1 -0
- stanwell/ui/dashboard/constants.py +21 -0
- stanwell/ui/dashboard/context.py +223 -0
- stanwell/ui/dashboard/dashboard.py +94 -0
- stanwell/ui/dashboard/helpers.py +90 -0
- stanwell/ui/dashboard/sidebar.py +449 -0
- stanwell/ui/dashboard/tabs/__init__.py +1 -0
- stanwell/ui/dashboard/tabs/overview.py +868 -0
- stanwell/ui/dashboard/tabs/projects.py +273 -0
- stanwell/ui/dashboard/tabs/tag_balances.py +779 -0
- stanwell/ui/dashboard/tabs/work_patterns.py +960 -0
- stanwell/ui/date_periods.py +86 -0
- stanwell-0.1.0.dist-info/METADATA +17 -0
- stanwell-0.1.0.dist-info/RECORD +43 -0
- stanwell-0.1.0.dist-info/WHEEL +4 -0
- stanwell-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|