praiselul 0.1.0__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.
- praiselul-0.1.0/PKG-INFO +55 -0
- praiselul-0.1.0/README.md +38 -0
- praiselul-0.1.0/praiselul/__init__.py +1 -0
- praiselul-0.1.0/praiselul/cli.py +150 -0
- praiselul-0.1.0/praiselul/config.py +55 -0
- praiselul-0.1.0/praiselul/duration.py +67 -0
- praiselul-0.1.0/praiselul/errors.py +8 -0
- praiselul-0.1.0/praiselul/plotting.py +27 -0
- praiselul-0.1.0/praiselul/praise/__init__.py +0 -0
- praiselul-0.1.0/praiselul/praise/praise_session.py +79 -0
- praiselul-0.1.0/praiselul/time.py +212 -0
- praiselul-0.1.0/praiselul.egg-info/PKG-INFO +55 -0
- praiselul-0.1.0/praiselul.egg-info/SOURCES.txt +18 -0
- praiselul-0.1.0/praiselul.egg-info/dependency_links.txt +1 -0
- praiselul-0.1.0/praiselul.egg-info/entry_points.txt +2 -0
- praiselul-0.1.0/praiselul.egg-info/requires.txt +6 -0
- praiselul-0.1.0/praiselul.egg-info/top_level.txt +1 -0
- praiselul-0.1.0/pyproject.toml +42 -0
- praiselul-0.1.0/setup.cfg +4 -0
- praiselul-0.1.0/tests/test_time.py +233 -0
praiselul-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: praiselul
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Overtime management for Praise
|
|
5
|
+
Author-email: guillaume@pafin.com
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/LysanderGG/praiselul
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: requests~=2.31
|
|
13
|
+
Requires-Dist: plotly~=5.18
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest~=8.0; extra == "dev"
|
|
16
|
+
Requires-Dist: ruff~=0.4; extra == "dev"
|
|
17
|
+
|
|
18
|
+
# praiselul
|
|
19
|
+
|
|
20
|
+
Overtime management CLI for [Praise](https://github.com/Cryptact/praise).
|
|
21
|
+
|
|
22
|
+
Mirrors [RecoLul](https://github.com/Maerig/recolul)'s interface, powered by Praise's REST API.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install praiselul
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Setup
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
praiselul config
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Prompts for Praise server URL, email, password, and hours per day.
|
|
37
|
+
|
|
38
|
+
## Commands
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
praiselul balance # Monthly overtime balance + workplace breakdown
|
|
42
|
+
praiselul balance --exclude-last-day
|
|
43
|
+
praiselul when # When to leave to avoid overtime
|
|
44
|
+
praiselul graph # Plotly overtime progression chart
|
|
45
|
+
praiselul graph --exclude-last-day
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Config
|
|
49
|
+
|
|
50
|
+
Stored in `~/.praiselul/config.ini`. Also supports environment variables:
|
|
51
|
+
|
|
52
|
+
- `PRAISE_URL`
|
|
53
|
+
- `PRAISE_EMAIL`
|
|
54
|
+
- `PRAISE_PASSWORD`
|
|
55
|
+
- `PRAISE_HOURS_PER_DAY` (default: 8)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# praiselul
|
|
2
|
+
|
|
3
|
+
Overtime management CLI for [Praise](https://github.com/Cryptact/praise).
|
|
4
|
+
|
|
5
|
+
Mirrors [RecoLul](https://github.com/Maerig/recolul)'s interface, powered by Praise's REST API.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install praiselul
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
praiselul config
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Prompts for Praise server URL, email, password, and hours per day.
|
|
20
|
+
|
|
21
|
+
## Commands
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
praiselul balance # Monthly overtime balance + workplace breakdown
|
|
25
|
+
praiselul balance --exclude-last-day
|
|
26
|
+
praiselul when # When to leave to avoid overtime
|
|
27
|
+
praiselul graph # Plotly overtime progression chart
|
|
28
|
+
praiselul graph --exclude-last-day
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Config
|
|
32
|
+
|
|
33
|
+
Stored in `~/.praiselul/config.ini`. Also supports environment variables:
|
|
34
|
+
|
|
35
|
+
- `PRAISE_URL`
|
|
36
|
+
- `PRAISE_EMAIL`
|
|
37
|
+
- `PRAISE_PASSWORD`
|
|
38
|
+
- `PRAISE_HOURS_PER_DAY` (default: 8)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from getpass import getpass
|
|
4
|
+
from typing import Any
|
|
5
|
+
from zoneinfo import ZoneInfo
|
|
6
|
+
|
|
7
|
+
from praiselul import __version__, plotting, time
|
|
8
|
+
from praiselul.config import Config, DEFAULT_HOURS_PER_DAY
|
|
9
|
+
from praiselul.duration import Duration
|
|
10
|
+
from praiselul.errors import NoClockInError
|
|
11
|
+
from praiselul.praise.praise_session import PraiseSession
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_tz(timesheet: dict[str, Any]) -> ZoneInfo:
|
|
15
|
+
return ZoneInfo(timesheet.get("timezone", "UTC"))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def balance(config: Config, exclude_last_day: bool) -> None:
|
|
19
|
+
timesheet = _get_timesheet(config)
|
|
20
|
+
tz = _get_tz(timesheet)
|
|
21
|
+
days = time.until_today(timesheet["days"], tz)
|
|
22
|
+
if exclude_last_day and len(days) > 1:
|
|
23
|
+
days = days[:-1]
|
|
24
|
+
|
|
25
|
+
overtime_balance = time.get_overtime_balance(days, config, tz)
|
|
26
|
+
print(f"Monthly overtime balance: {overtime_balance}")
|
|
27
|
+
|
|
28
|
+
workplace_times = time.get_workplace_times(timesheet["summary"])
|
|
29
|
+
if workplace_times:
|
|
30
|
+
print("Total time per workplace:")
|
|
31
|
+
for workplace, total_work_time in workplace_times.items():
|
|
32
|
+
print(f" {workplace}: {total_work_time}")
|
|
33
|
+
|
|
34
|
+
summary = timesheet["summary"]
|
|
35
|
+
if summary.get("hasRemoteAllowance"):
|
|
36
|
+
remote_budget = Duration.from_minutes(summary.get("requiredMinutes", 0) - summary.get("requiredOnSiteMinutes", 0))
|
|
37
|
+
print(f"Maximum WFH time this month: {remote_budget}")
|
|
38
|
+
|
|
39
|
+
if exclude_last_day:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
today = time.get_today_record(days, tz)
|
|
43
|
+
if today:
|
|
44
|
+
label = time.day_label(today["date"])
|
|
45
|
+
print(f"\nLast day {label}")
|
|
46
|
+
clock_in_time = time.get_clock_in_time(today, tz)
|
|
47
|
+
if clock_in_time:
|
|
48
|
+
print(f" Clock-in: {clock_in_time}")
|
|
49
|
+
actual_mins = time._day_actual_minutes(today, tz)
|
|
50
|
+
print(f" Working hours: {Duration.from_minutes(actual_mins)}")
|
|
51
|
+
break_mins = Duration.from_minutes(today.get("breakMinutes"))
|
|
52
|
+
if break_mins:
|
|
53
|
+
print(f" Break: {break_mins}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def when_to_leave(config: Config) -> None:
|
|
57
|
+
try:
|
|
58
|
+
timesheet = _get_timesheet(config)
|
|
59
|
+
tz = _get_tz(timesheet)
|
|
60
|
+
leave_times = time.get_leave_time(timesheet["days"], config, tz)
|
|
61
|
+
except NoClockInError:
|
|
62
|
+
print("You have already clocked out.")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
if len(leave_times) == 1:
|
|
66
|
+
break_msg = "(break time included)" if leave_times[0].includes_break else "(break time not included)"
|
|
67
|
+
print(f"Leave at {leave_times[0].min_time} to avoid overtime {break_msg}.")
|
|
68
|
+
else:
|
|
69
|
+
print(
|
|
70
|
+
f"Leave between {leave_times[0].min_time} and {leave_times[0].max_time}, or "
|
|
71
|
+
f"after {leave_times[1].min_time}."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def update_config() -> None:
|
|
76
|
+
praise_url = input("praise.url: ")
|
|
77
|
+
praise_email = input("praise.email: ")
|
|
78
|
+
praise_password = getpass("praise.password: ")
|
|
79
|
+
hours_per_day = input(f"praise.hoursPerDay [{DEFAULT_HOURS_PER_DAY}]: ") or str(DEFAULT_HOURS_PER_DAY)
|
|
80
|
+
config = Config(
|
|
81
|
+
praise_url=praise_url,
|
|
82
|
+
praise_email=praise_email,
|
|
83
|
+
praise_password=praise_password,
|
|
84
|
+
hours_per_day=int(hours_per_day),
|
|
85
|
+
)
|
|
86
|
+
config.save()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def graph(config: Config, exclude_last_day: bool) -> None:
|
|
90
|
+
timesheet = _get_timesheet(config)
|
|
91
|
+
tz = _get_tz(timesheet)
|
|
92
|
+
days = time.until_today(timesheet["days"], tz)
|
|
93
|
+
if exclude_last_day and len(days) > 1:
|
|
94
|
+
days = days[:-1]
|
|
95
|
+
labels, history = time.get_overtime_history(days, config, tz)
|
|
96
|
+
plotting.plot_overtime_balance_history(labels, history)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main() -> None:
|
|
100
|
+
parser = argparse.ArgumentParser(prog="praiselul")
|
|
101
|
+
parser.add_argument("-v", "--version", action="version", version=__version__)
|
|
102
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
103
|
+
|
|
104
|
+
balance_parser = subparsers.add_parser("balance", help="Calculate overtime balance")
|
|
105
|
+
balance_parser.add_argument(
|
|
106
|
+
"--exclude-last-day",
|
|
107
|
+
action="store_true",
|
|
108
|
+
help="Exclude last/current day from the calculation",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
subparsers.add_parser("when", help="Calculate at which time to leave to avoid overtime this month")
|
|
112
|
+
|
|
113
|
+
subparsers.add_parser("config", help="Init or update config")
|
|
114
|
+
|
|
115
|
+
graph_parser = subparsers.add_parser("graph", help="Display a graph of overtime balance over the month")
|
|
116
|
+
graph_parser.add_argument(
|
|
117
|
+
"--exclude-last-day",
|
|
118
|
+
action="store_true",
|
|
119
|
+
help="Exclude last/current day from the graph",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
args = parser.parse_args(sys.argv[1:])
|
|
123
|
+
if args.command == "config":
|
|
124
|
+
update_config()
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
config = Config.from_env() or Config.load()
|
|
128
|
+
if not config:
|
|
129
|
+
raise RuntimeError("No config found. Run: praiselul config")
|
|
130
|
+
|
|
131
|
+
match args.command:
|
|
132
|
+
case "balance":
|
|
133
|
+
balance(config, exclude_last_day=args.exclude_last_day)
|
|
134
|
+
case "when":
|
|
135
|
+
when_to_leave(config)
|
|
136
|
+
case "graph":
|
|
137
|
+
graph(config, exclude_last_day=args.exclude_last_day)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _get_timesheet(config: Config) -> dict[str, Any]:
|
|
141
|
+
with PraiseSession(
|
|
142
|
+
base_url=config.praise_url,
|
|
143
|
+
email=config.praise_email,
|
|
144
|
+
password=config.praise_password,
|
|
145
|
+
) as session:
|
|
146
|
+
return session.get_timesheet()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
main()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import configparser
|
|
2
|
+
import os
|
|
3
|
+
import os.path
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
DEFAULT_CONFIG_DIR = os.path.expanduser("~/.praiselul")
|
|
7
|
+
DEFAULT_CONFIG_PATH = os.path.join(DEFAULT_CONFIG_DIR, "config.ini")
|
|
8
|
+
|
|
9
|
+
DEFAULT_HOURS_PER_DAY = 8
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Config:
|
|
14
|
+
praise_url: str
|
|
15
|
+
praise_email: str
|
|
16
|
+
praise_password: str
|
|
17
|
+
hours_per_day: int = DEFAULT_HOURS_PER_DAY
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def from_env(cls):
|
|
21
|
+
try:
|
|
22
|
+
return cls(
|
|
23
|
+
praise_url=os.environ["PRAISE_URL"],
|
|
24
|
+
praise_email=os.environ["PRAISE_EMAIL"],
|
|
25
|
+
praise_password=os.environ["PRAISE_PASSWORD"],
|
|
26
|
+
hours_per_day=int(os.getenv("PRAISE_HOURS_PER_DAY", DEFAULT_HOURS_PER_DAY)),
|
|
27
|
+
)
|
|
28
|
+
except KeyError:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def load(cls, path: str = DEFAULT_CONFIG_PATH):
|
|
33
|
+
if not os.path.isfile(path):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
config = configparser.ConfigParser(interpolation=None)
|
|
37
|
+
config.read(path)
|
|
38
|
+
return cls(
|
|
39
|
+
praise_url=config["praise"]["url"],
|
|
40
|
+
praise_email=config["praise"]["email"],
|
|
41
|
+
praise_password=config["praise"]["password"],
|
|
42
|
+
hours_per_day=int(config["praise"].get("hoursPerDay", str(DEFAULT_HOURS_PER_DAY))),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def save(self, path: str = DEFAULT_CONFIG_PATH):
|
|
46
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
47
|
+
config = configparser.ConfigParser(interpolation=None)
|
|
48
|
+
config["praise"] = {
|
|
49
|
+
"url": self.praise_url,
|
|
50
|
+
"email": self.praise_email,
|
|
51
|
+
"password": self.praise_password,
|
|
52
|
+
"hoursPerDay": str(self.hours_per_day),
|
|
53
|
+
}
|
|
54
|
+
with open(path, "w") as config_file:
|
|
55
|
+
config.write(config_file)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Duration:
|
|
5
|
+
@classmethod
|
|
6
|
+
def parse(cls, duration: str):
|
|
7
|
+
if duration == "":
|
|
8
|
+
return cls(0)
|
|
9
|
+
hours, minutes = duration.split(":")
|
|
10
|
+
total_minutes = 60 * int(hours) + int(minutes)
|
|
11
|
+
return cls(total_minutes)
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def now(cls):
|
|
15
|
+
return cls.parse(datetime.now().strftime("%H:%M"))
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def from_minutes(cls, minutes: int | float | None):
|
|
19
|
+
if minutes is None:
|
|
20
|
+
return cls(0)
|
|
21
|
+
return cls(int(minutes))
|
|
22
|
+
|
|
23
|
+
def __init__(self, minutes: int = 0):
|
|
24
|
+
self.minutes: int = minutes
|
|
25
|
+
|
|
26
|
+
def __repr__(self) -> str:
|
|
27
|
+
return f"{'-' if self.minutes < 0 else ''}{abs(self.minutes) // 60:02}:{abs(self.minutes) % 60:02}"
|
|
28
|
+
|
|
29
|
+
__str__ = __repr__
|
|
30
|
+
|
|
31
|
+
def __eq__(self, other):
|
|
32
|
+
if other is None:
|
|
33
|
+
return False
|
|
34
|
+
return self.minutes == other.minutes
|
|
35
|
+
|
|
36
|
+
def __neq__(self, other):
|
|
37
|
+
return self.minutes != other.minutes
|
|
38
|
+
|
|
39
|
+
def __add__(self, other):
|
|
40
|
+
return Duration(self.minutes + other.minutes)
|
|
41
|
+
|
|
42
|
+
def __sub__(self, other):
|
|
43
|
+
return Duration(self.minutes - other.minutes)
|
|
44
|
+
|
|
45
|
+
def __neg__(self):
|
|
46
|
+
return Duration(-self.minutes)
|
|
47
|
+
|
|
48
|
+
def __mul__(self, other):
|
|
49
|
+
return Duration(other * self.minutes)
|
|
50
|
+
|
|
51
|
+
def __lt__(self, other):
|
|
52
|
+
return self.minutes < other.minutes
|
|
53
|
+
|
|
54
|
+
def __gt__(self, other):
|
|
55
|
+
return self.minutes > other.minutes
|
|
56
|
+
|
|
57
|
+
def __le__(self, other):
|
|
58
|
+
return self.minutes <= other.minutes
|
|
59
|
+
|
|
60
|
+
def __ge__(self, other):
|
|
61
|
+
return self.minutes >= other.minutes
|
|
62
|
+
|
|
63
|
+
def __abs__(self):
|
|
64
|
+
return Duration(abs(self.minutes))
|
|
65
|
+
|
|
66
|
+
def __bool__(self):
|
|
67
|
+
return self.minutes > 0
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
class InvalidPraiseLoginError(Exception):
|
|
2
|
+
"""Invalid Praise login information"""
|
|
3
|
+
def __init__(self):
|
|
4
|
+
super().__init__("Invalid Praise login information. Run praiselul config")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class NoClockInError(Exception):
|
|
8
|
+
"""A clock-in time was expected but wasn't found"""
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import plotly.graph_objects as go
|
|
2
|
+
|
|
3
|
+
from praiselul.duration import Duration
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def plot_overtime_balance_history(days: list[str], overtime_history: list[Duration]) -> None:
|
|
7
|
+
cumulative_overtime_history = []
|
|
8
|
+
for overtime in overtime_history:
|
|
9
|
+
last_cumulative_overtime = (
|
|
10
|
+
cumulative_overtime_history[-1] if cumulative_overtime_history
|
|
11
|
+
else Duration()
|
|
12
|
+
)
|
|
13
|
+
cumulative_overtime_history.append(last_cumulative_overtime + overtime)
|
|
14
|
+
|
|
15
|
+
fig = go.Figure(
|
|
16
|
+
data=go.Scatter(
|
|
17
|
+
x=days,
|
|
18
|
+
y=[duration.minutes for duration in cumulative_overtime_history],
|
|
19
|
+
text=[str(duration) for duration in cumulative_overtime_history],
|
|
20
|
+
hovertemplate="%{x} %{text}<extra></extra>",
|
|
21
|
+
mode="lines+markers",
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
fig.update_layout(
|
|
25
|
+
yaxis_title="Overtime balance (minutes)",
|
|
26
|
+
)
|
|
27
|
+
fig.show()
|
|
File without changes
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from praiselul.errors import InvalidPraiseLoginError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PraiseSession:
|
|
12
|
+
def __init__(self, base_url: str, email: str, password: str):
|
|
13
|
+
if not base_url.startswith(("http://", "https://")):
|
|
14
|
+
base_url = f"https://{base_url}"
|
|
15
|
+
self._base_url: str = base_url.rstrip("/")
|
|
16
|
+
self._email: str = email
|
|
17
|
+
self._password: str = password
|
|
18
|
+
self._session: requests.Session | None = None
|
|
19
|
+
|
|
20
|
+
def __enter__(self):
|
|
21
|
+
self._session = requests.Session()
|
|
22
|
+
self._fetch_build_version()
|
|
23
|
+
self._login()
|
|
24
|
+
return self
|
|
25
|
+
|
|
26
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
27
|
+
self._session.close()
|
|
28
|
+
self._session = None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def session(self) -> requests.Session:
|
|
32
|
+
assert self._session, "PraiseSession should be used as a context manager"
|
|
33
|
+
return self._session
|
|
34
|
+
|
|
35
|
+
def get_timesheet(self, year: int | None = None, month: int | None = None) -> dict[str, Any]:
|
|
36
|
+
now = datetime.now()
|
|
37
|
+
year = year or now.year
|
|
38
|
+
month = month or now.month
|
|
39
|
+
response = self.session.get(
|
|
40
|
+
f"{self._base_url}/api/time/my-timesheet",
|
|
41
|
+
params={"year": year, "month": month, "locale": "en"},
|
|
42
|
+
)
|
|
43
|
+
response.raise_for_status()
|
|
44
|
+
data = response.json()
|
|
45
|
+
if not data.get("success"):
|
|
46
|
+
raise RuntimeError(f"API error: {data.get('error', {}).get('code', 'unknown')}")
|
|
47
|
+
return data["data"]
|
|
48
|
+
|
|
49
|
+
def get_clock_status(self) -> dict[str, Any]:
|
|
50
|
+
response = self.session.get(
|
|
51
|
+
f"{self._base_url}/api/time/clock/status",
|
|
52
|
+
params={"locale": "en"},
|
|
53
|
+
)
|
|
54
|
+
response.raise_for_status()
|
|
55
|
+
data = response.json()
|
|
56
|
+
if not data.get("success"):
|
|
57
|
+
raise RuntimeError(f"API error: {data.get('error', {}).get('code', 'unknown')}")
|
|
58
|
+
return data["data"]
|
|
59
|
+
|
|
60
|
+
def _fetch_build_version(self):
|
|
61
|
+
"""Fetch the server's build version from /api/health (no version check on that route)
|
|
62
|
+
and set it as a default header for all subsequent requests."""
|
|
63
|
+
response = self.session.get(f"{self._base_url}/api/health")
|
|
64
|
+
response.raise_for_status()
|
|
65
|
+
version = response.json().get("version")
|
|
66
|
+
if version:
|
|
67
|
+
self.session.headers["X-Build-Version"] = version
|
|
68
|
+
|
|
69
|
+
def _login(self):
|
|
70
|
+
response = self.session.post(
|
|
71
|
+
f"{self._base_url}/api/auth/login",
|
|
72
|
+
json={"email": self._email, "password": self._password},
|
|
73
|
+
)
|
|
74
|
+
if response.status_code == 401:
|
|
75
|
+
raise InvalidPraiseLoginError()
|
|
76
|
+
response.raise_for_status()
|
|
77
|
+
data = response.json()
|
|
78
|
+
if not data.get("success"):
|
|
79
|
+
raise InvalidPraiseLoginError()
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
from datetime import datetime, timezone, timedelta
|
|
5
|
+
from typing import Any
|
|
6
|
+
from zoneinfo import ZoneInfo
|
|
7
|
+
|
|
8
|
+
from praiselul.config import Config
|
|
9
|
+
from praiselul.duration import Duration
|
|
10
|
+
from praiselul.errors import NoClockInError
|
|
11
|
+
|
|
12
|
+
_MIN_HOURS_FOR_MANDATORY_BREAK = Duration(6 * 60)
|
|
13
|
+
|
|
14
|
+
# Japanese weekday abbreviations (Mon=0 .. Sun=6)
|
|
15
|
+
_WEEKDAYS_JA = ["月", "火", "水", "木", "金", "土", "日"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def day_label(date_str: str) -> str:
|
|
19
|
+
"""Convert YYYY-MM-DD to 'M/D(曜)' format like RecoLul."""
|
|
20
|
+
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
|
21
|
+
weekday = _WEEKDAYS_JA[dt.weekday()]
|
|
22
|
+
return f"{dt.month}/{dt.day}({weekday})"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def until_today(days: list[dict[str, Any]], tz: ZoneInfo | None = None) -> list[dict[str, Any]]:
|
|
26
|
+
"""Return only days up to and including today."""
|
|
27
|
+
if tz:
|
|
28
|
+
today = datetime.now(tz).strftime("%Y-%m-%d")
|
|
29
|
+
else:
|
|
30
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
31
|
+
return [d for d in days if d["date"] <= today]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_NON_WORKING_DAY_TYPES = {"scheduled_rest_day", "statutory_rest_day", "holiday"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_working_day(day: dict[str, Any]) -> bool:
|
|
38
|
+
return day.get("dayType") not in _NON_WORKING_DAY_TYPES
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _day_expected_minutes(day: dict[str, Any], config: Config) -> int:
|
|
42
|
+
"""Expected minutes for overtime calculation.
|
|
43
|
+
|
|
44
|
+
Uses config.hours_per_day for working days (matching RecoLul's behavior)
|
|
45
|
+
and 0 for non-working days (rest days, holidays).
|
|
46
|
+
"""
|
|
47
|
+
if not _is_working_day(day):
|
|
48
|
+
return 0
|
|
49
|
+
return config.hours_per_day * 60
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _has_activity(day: dict[str, Any], config: Config) -> bool:
|
|
53
|
+
"""Whether this day has work or expected work."""
|
|
54
|
+
actual = day.get("actualWorkMinutes") or 0
|
|
55
|
+
expected = _day_expected_minutes(day, config)
|
|
56
|
+
return actual > 0 or expected > 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _get_current_work_minutes(day: dict[str, Any], tz: ZoneInfo) -> int:
|
|
60
|
+
"""For an open session (clocked in, no clock out), compute minutes worked so far."""
|
|
61
|
+
clock_in = _parse_iso_to_local(day.get("clockIn"), tz)
|
|
62
|
+
if not clock_in:
|
|
63
|
+
return 0
|
|
64
|
+
now = datetime.now(tz)
|
|
65
|
+
diff = int((now - clock_in).total_seconds() / 60)
|
|
66
|
+
return max(0, diff)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _day_actual_minutes(day: dict[str, Any], tz: ZoneInfo) -> int:
|
|
70
|
+
"""Get actual work minutes for a day, computing live value for open sessions."""
|
|
71
|
+
actual = day.get("actualWorkMinutes")
|
|
72
|
+
if actual is not None:
|
|
73
|
+
return int(actual)
|
|
74
|
+
# Open session — compute from clock-in to now
|
|
75
|
+
if _is_open_session(day):
|
|
76
|
+
return _get_current_work_minutes(day, tz)
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_overtime_history(
|
|
81
|
+
days: list[dict[str, Any]], config: Config, tz: ZoneInfo,
|
|
82
|
+
) -> tuple[list[str], list[Duration]]:
|
|
83
|
+
"""Compute per-day overtime from Praise daily records.
|
|
84
|
+
|
|
85
|
+
Returns (day_labels, daily_overtime_list).
|
|
86
|
+
"""
|
|
87
|
+
labels = []
|
|
88
|
+
overtime_history = []
|
|
89
|
+
for day in days:
|
|
90
|
+
actual = _day_actual_minutes(day, tz)
|
|
91
|
+
expected = _day_expected_minutes(day, config)
|
|
92
|
+
|
|
93
|
+
if not (actual or expected):
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
labels.append(day_label(day["date"]))
|
|
97
|
+
overtime_history.append(Duration(actual - expected))
|
|
98
|
+
|
|
99
|
+
return labels, overtime_history
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_overtime_balance(days: list[dict[str, Any]], config: Config, tz: ZoneInfo) -> Duration:
|
|
103
|
+
"""Sum of daily overtime across all provided days."""
|
|
104
|
+
_, history = get_overtime_history(days, config, tz)
|
|
105
|
+
return sum(history, Duration())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_workplace_times(summary: dict[str, Any]) -> dict[str, Duration]:
|
|
109
|
+
"""Extract workplace breakdown from the timesheet summary."""
|
|
110
|
+
result = {}
|
|
111
|
+
on_site = summary.get("onSiteMinutes", 0)
|
|
112
|
+
remote = summary.get("remoteMinutes", 0)
|
|
113
|
+
if on_site:
|
|
114
|
+
result["On-site"] = Duration.from_minutes(on_site)
|
|
115
|
+
if remote:
|
|
116
|
+
result["Remote"] = Duration.from_minutes(remote)
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_today_record(days: list[dict[str, Any]], tz: ZoneInfo) -> dict[str, Any] | None:
|
|
121
|
+
"""Get today's daily record if it exists."""
|
|
122
|
+
today = datetime.now(tz).strftime("%Y-%m-%d")
|
|
123
|
+
for day in days:
|
|
124
|
+
if day["date"] == today:
|
|
125
|
+
return day
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse_iso_to_local(iso_str: str | None, tz: ZoneInfo) -> datetime | None:
|
|
130
|
+
"""Parse an ISO datetime string and convert to the given timezone."""
|
|
131
|
+
if not iso_str:
|
|
132
|
+
return None
|
|
133
|
+
dt = datetime.fromisoformat(iso_str)
|
|
134
|
+
if dt.tzinfo is None:
|
|
135
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
136
|
+
return dt.astimezone(tz)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_clock_in_time(day: dict[str, Any], tz: ZoneInfo) -> Duration | None:
|
|
140
|
+
"""Extract clock-in time as a Duration (local time) from a daily record."""
|
|
141
|
+
dt = _parse_iso_to_local(day.get("clockIn"), tz)
|
|
142
|
+
if not dt:
|
|
143
|
+
return None
|
|
144
|
+
return Duration(dt.hour * 60 + dt.minute)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclasses.dataclass
|
|
148
|
+
class LeaveTime:
|
|
149
|
+
includes_break: bool
|
|
150
|
+
min_time: Duration
|
|
151
|
+
max_time: Duration | None = None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_leave_time(days: list[dict[str, Any]], config: Config, tz: ZoneInfo) -> list[LeaveTime]:
|
|
155
|
+
"""Calculate when to leave to reach zero monthly overtime.
|
|
156
|
+
|
|
157
|
+
Uses the same 3-scenario break logic as RecoLul.
|
|
158
|
+
"""
|
|
159
|
+
today_days = until_today(days, tz)
|
|
160
|
+
if not today_days:
|
|
161
|
+
raise NoClockInError()
|
|
162
|
+
|
|
163
|
+
today = today_days[-1]
|
|
164
|
+
clock_in = get_clock_in_time(today, tz)
|
|
165
|
+
|
|
166
|
+
# If no clock-in or already clocked out, raise
|
|
167
|
+
if not clock_in:
|
|
168
|
+
raise NoClockInError()
|
|
169
|
+
if today.get("clockOut") and not _is_open_session(today):
|
|
170
|
+
raise NoClockInError()
|
|
171
|
+
|
|
172
|
+
# Compute overtime balance from all days before today
|
|
173
|
+
previous_days = today_days[:-1]
|
|
174
|
+
overtime_balance = get_overtime_balance(previous_days, config, tz)
|
|
175
|
+
|
|
176
|
+
day_base_hours = Duration(config.hours_per_day * 60)
|
|
177
|
+
required_today = day_base_hours - overtime_balance
|
|
178
|
+
|
|
179
|
+
leave_time_without_break = clock_in + required_today
|
|
180
|
+
leave_time_with_break = clock_in + required_today + Duration(60)
|
|
181
|
+
|
|
182
|
+
if required_today > _MIN_HOURS_FOR_MANDATORY_BREAK:
|
|
183
|
+
return [
|
|
184
|
+
LeaveTime(
|
|
185
|
+
includes_break=True,
|
|
186
|
+
min_time=leave_time_with_break,
|
|
187
|
+
)
|
|
188
|
+
]
|
|
189
|
+
if required_today > _MIN_HOURS_FOR_MANDATORY_BREAK - Duration(60):
|
|
190
|
+
# Between 5 and 6 hours: two windows
|
|
191
|
+
first_leave_time = LeaveTime(
|
|
192
|
+
includes_break=False,
|
|
193
|
+
min_time=leave_time_without_break,
|
|
194
|
+
max_time=clock_in + _MIN_HOURS_FOR_MANDATORY_BREAK,
|
|
195
|
+
)
|
|
196
|
+
second_leave_time = LeaveTime(
|
|
197
|
+
includes_break=True,
|
|
198
|
+
min_time=leave_time_with_break,
|
|
199
|
+
)
|
|
200
|
+
return [first_leave_time, second_leave_time]
|
|
201
|
+
return [
|
|
202
|
+
LeaveTime(
|
|
203
|
+
includes_break=False,
|
|
204
|
+
min_time=leave_time_without_break,
|
|
205
|
+
)
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _is_open_session(day: dict[str, Any]) -> bool:
|
|
210
|
+
"""Check if the day has an open (not yet clocked out) session."""
|
|
211
|
+
sessions = day.get("sessions", [])
|
|
212
|
+
return any(s.get("clockOut") is None for s in sessions)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: praiselul
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Overtime management for Praise
|
|
5
|
+
Author-email: guillaume@pafin.com
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/LysanderGG/praiselul
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: requests~=2.31
|
|
13
|
+
Requires-Dist: plotly~=5.18
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest~=8.0; extra == "dev"
|
|
16
|
+
Requires-Dist: ruff~=0.4; extra == "dev"
|
|
17
|
+
|
|
18
|
+
# praiselul
|
|
19
|
+
|
|
20
|
+
Overtime management CLI for [Praise](https://github.com/Cryptact/praise).
|
|
21
|
+
|
|
22
|
+
Mirrors [RecoLul](https://github.com/Maerig/recolul)'s interface, powered by Praise's REST API.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install praiselul
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Setup
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
praiselul config
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Prompts for Praise server URL, email, password, and hours per day.
|
|
37
|
+
|
|
38
|
+
## Commands
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
praiselul balance # Monthly overtime balance + workplace breakdown
|
|
42
|
+
praiselul balance --exclude-last-day
|
|
43
|
+
praiselul when # When to leave to avoid overtime
|
|
44
|
+
praiselul graph # Plotly overtime progression chart
|
|
45
|
+
praiselul graph --exclude-last-day
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Config
|
|
49
|
+
|
|
50
|
+
Stored in `~/.praiselul/config.ini`. Also supports environment variables:
|
|
51
|
+
|
|
52
|
+
- `PRAISE_URL`
|
|
53
|
+
- `PRAISE_EMAIL`
|
|
54
|
+
- `PRAISE_PASSWORD`
|
|
55
|
+
- `PRAISE_HOURS_PER_DAY` (default: 8)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
praiselul/__init__.py
|
|
4
|
+
praiselul/cli.py
|
|
5
|
+
praiselul/config.py
|
|
6
|
+
praiselul/duration.py
|
|
7
|
+
praiselul/errors.py
|
|
8
|
+
praiselul/plotting.py
|
|
9
|
+
praiselul/time.py
|
|
10
|
+
praiselul.egg-info/PKG-INFO
|
|
11
|
+
praiselul.egg-info/SOURCES.txt
|
|
12
|
+
praiselul.egg-info/dependency_links.txt
|
|
13
|
+
praiselul.egg-info/entry_points.txt
|
|
14
|
+
praiselul.egg-info/requires.txt
|
|
15
|
+
praiselul.egg-info/top_level.txt
|
|
16
|
+
praiselul/praise/__init__.py
|
|
17
|
+
praiselul/praise/praise_session.py
|
|
18
|
+
tests/test_time.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
praiselul
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools >= 68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "praiselul"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Overtime management for Praise"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ email = "guillaume@pafin.com" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
dependencies = [
|
|
21
|
+
"requests~=2.31",
|
|
22
|
+
"plotly~=5.18",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Repository = "https://github.com/LysanderGG/praiselul"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
praiselul = "praiselul.cli:main"
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest~=8.0",
|
|
34
|
+
"ruff~=0.4",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
line-length = 120
|
|
39
|
+
target-version = "py310"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
include = ["praiselul", "praiselul.*"]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
from zoneinfo import ZoneInfo
|
|
2
|
+
|
|
3
|
+
from praiselul.config import Config
|
|
4
|
+
from praiselul.duration import Duration
|
|
5
|
+
from praiselul.time import LeaveTime, get_leave_time, get_overtime_balance, get_overtime_history, get_workplace_times
|
|
6
|
+
|
|
7
|
+
DEFAULT_CONFIG = Config(praise_url="", praise_email="", praise_password="") # 8h/day
|
|
8
|
+
PART_TIME_CONFIG = Config(praise_url="", praise_email="", praise_password="", hours_per_day=6)
|
|
9
|
+
|
|
10
|
+
# Use UTC in tests so clock-in timestamps don't need offset adjustment
|
|
11
|
+
TZ = ZoneInfo("UTC")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _make_day(
|
|
15
|
+
date: str,
|
|
16
|
+
day_type: str = "working_day",
|
|
17
|
+
actual_work_minutes: int | None = None,
|
|
18
|
+
clock_in: str | None = None,
|
|
19
|
+
clock_out: str | None = None,
|
|
20
|
+
break_minutes: int | None = None,
|
|
21
|
+
sessions: list | None = None,
|
|
22
|
+
) -> dict:
|
|
23
|
+
return {
|
|
24
|
+
"date": date,
|
|
25
|
+
"dayType": day_type,
|
|
26
|
+
"actualWorkMinutes": actual_work_minutes,
|
|
27
|
+
"expectedMinutes": 480, # Praise always sends this; we ignore it for overtime calc
|
|
28
|
+
"clockIn": clock_in,
|
|
29
|
+
"clockOut": clock_out,
|
|
30
|
+
"breakMinutes": break_minutes,
|
|
31
|
+
"sessions": sessions or [],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# --- get_overtime_history ---
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_overtime_history_normal_days():
|
|
39
|
+
days = [
|
|
40
|
+
_make_day("2026-04-07", actual_work_minutes=525), # 8:45 worked, 8:00 expected → +45
|
|
41
|
+
_make_day("2026-04-08", actual_work_minutes=505), # 8:25 → +25
|
|
42
|
+
_make_day("2026-04-09", actual_work_minutes=432), # 7:12 → -48
|
|
43
|
+
]
|
|
44
|
+
labels, history = get_overtime_history(days, DEFAULT_CONFIG, TZ)
|
|
45
|
+
assert labels == ["4/7(火)", "4/8(水)", "4/9(木)"]
|
|
46
|
+
assert history == [Duration(45), Duration(25), Duration(-48)]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_overtime_history_skips_rest_days_with_no_activity():
|
|
50
|
+
days = [
|
|
51
|
+
_make_day("2026-04-07", actual_work_minutes=480),
|
|
52
|
+
_make_day("2026-04-08", day_type="statutory_rest_day", actual_work_minutes=None),
|
|
53
|
+
_make_day("2026-04-09", actual_work_minutes=480),
|
|
54
|
+
]
|
|
55
|
+
labels, history = get_overtime_history(days, DEFAULT_CONFIG, TZ)
|
|
56
|
+
assert labels == ["4/7(火)", "4/9(木)"]
|
|
57
|
+
assert history == [Duration(0), Duration(0)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_overtime_history_worked_holiday():
|
|
61
|
+
"""Working on a holiday (expected=0 via dayType) counts all hours as overtime."""
|
|
62
|
+
days = [
|
|
63
|
+
_make_day("2026-04-08", actual_work_minutes=480),
|
|
64
|
+
_make_day("2026-04-09", day_type="holiday", actual_work_minutes=203), # 3:23 worked, 0 expected
|
|
65
|
+
]
|
|
66
|
+
labels, history = get_overtime_history(days, DEFAULT_CONFIG, TZ)
|
|
67
|
+
assert labels == ["4/8(水)", "4/9(木)"]
|
|
68
|
+
assert history == [Duration(0), Duration(203)]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_overtime_history_worked_rest_day():
|
|
72
|
+
"""Working on a rest day counts all hours as overtime (expected=0)."""
|
|
73
|
+
days = [
|
|
74
|
+
_make_day("2026-04-07", actual_work_minutes=480),
|
|
75
|
+
_make_day("2026-04-08", day_type="scheduled_rest_day", actual_work_minutes=180), # 3h worked
|
|
76
|
+
]
|
|
77
|
+
_, history = get_overtime_history(days, DEFAULT_CONFIG, TZ)
|
|
78
|
+
assert history == [Duration(0), Duration(180)]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_overtime_history_part_time():
|
|
82
|
+
"""Part-time: config.hours_per_day=6 means expected=360 per working day."""
|
|
83
|
+
days = [
|
|
84
|
+
_make_day("2026-04-07", actual_work_minutes=494), # 8:14 - 6:00 = +134
|
|
85
|
+
_make_day("2026-04-08", actual_work_minutes=488), # 8:08 - 6:00 = +128
|
|
86
|
+
]
|
|
87
|
+
_, history = get_overtime_history(days, PART_TIME_CONFIG, TZ)
|
|
88
|
+
assert history == [Duration(134), Duration(128)]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --- get_overtime_balance ---
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_overtime_balance():
|
|
95
|
+
days = [
|
|
96
|
+
_make_day("2026-04-07", actual_work_minutes=525), # +45
|
|
97
|
+
_make_day("2026-04-08", actual_work_minutes=505), # +25
|
|
98
|
+
_make_day("2026-04-09", actual_work_minutes=432), # -48
|
|
99
|
+
]
|
|
100
|
+
assert get_overtime_balance(days, DEFAULT_CONFIG, TZ) == Duration(22)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --- get_workplace_times ---
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_workplace_times():
|
|
107
|
+
summary = {"onSiteMinutes": 2400, "remoteMinutes": 600}
|
|
108
|
+
result = get_workplace_times(summary)
|
|
109
|
+
assert result == {"On-site": Duration(2400), "Remote": Duration(600)}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_workplace_times_no_remote():
|
|
113
|
+
summary = {"onSiteMinutes": 2400, "remoteMinutes": 0}
|
|
114
|
+
result = get_workplace_times(summary)
|
|
115
|
+
assert result == {"On-site": Duration(2400)}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --- get_leave_time ---
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _make_open_day(date: str, clock_in: str) -> dict:
|
|
122
|
+
"""Make a day with an open session (clocked in, not yet clocked out)."""
|
|
123
|
+
return _make_day(
|
|
124
|
+
date=date,
|
|
125
|
+
actual_work_minutes=None,
|
|
126
|
+
clock_in=clock_in,
|
|
127
|
+
sessions=[{"clockIn": clock_in, "clockOut": None}],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_leave_time_with_break():
|
|
132
|
+
"""Required > 6h → single window with break."""
|
|
133
|
+
days = [
|
|
134
|
+
# Previous day: 9 min overtime → required_today = 480-9 = 471 (>360) → break
|
|
135
|
+
_make_day("2026-04-07", actual_work_minutes=489),
|
|
136
|
+
_make_open_day("2026-04-08", clock_in="2026-04-08T09:00:00Z"),
|
|
137
|
+
]
|
|
138
|
+
leave_times = get_leave_time(days, DEFAULT_CONFIG, TZ)
|
|
139
|
+
# leave = 09:00 + 471min + 60min break = 17:51
|
|
140
|
+
assert leave_times == [
|
|
141
|
+
LeaveTime(includes_break=True, min_time=Duration.parse("17:51")),
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_leave_time_no_break():
|
|
146
|
+
"""Required < 5h → single window without break."""
|
|
147
|
+
days = [
|
|
148
|
+
# Previous days: +200min overtime → required_today = 480-200 = 280 (<300) → no break
|
|
149
|
+
_make_day("2026-04-07", actual_work_minutes=680),
|
|
150
|
+
_make_open_day("2026-04-08", clock_in="2026-04-08T09:00:00Z"),
|
|
151
|
+
]
|
|
152
|
+
leave_times = get_leave_time(days, DEFAULT_CONFIG, TZ)
|
|
153
|
+
# leave = 09:00 + 280min = 13:40
|
|
154
|
+
assert leave_times == [
|
|
155
|
+
LeaveTime(includes_break=False, min_time=Duration.parse("13:40")),
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_double_leave_time():
|
|
160
|
+
"""Required 5-6h → two windows."""
|
|
161
|
+
days = [
|
|
162
|
+
# Previous days: +150min overtime → required_today = 480-150 = 330 (between 300 and 360)
|
|
163
|
+
_make_day("2026-04-07", actual_work_minutes=630),
|
|
164
|
+
_make_open_day("2026-04-08", clock_in="2026-04-08T09:00:00Z"),
|
|
165
|
+
]
|
|
166
|
+
leave_times = get_leave_time(days, DEFAULT_CONFIG, TZ)
|
|
167
|
+
assert leave_times == [
|
|
168
|
+
LeaveTime(includes_break=False, min_time=Duration.parse("14:30"), max_time=Duration.parse("15:00")),
|
|
169
|
+
LeaveTime(includes_break=True, min_time=Duration.parse("15:30")),
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_leave_time_negative_overtime():
|
|
174
|
+
"""Negative overtime balance means more hours required today."""
|
|
175
|
+
days = [
|
|
176
|
+
# Previous day: -30min overtime → required_today = 480+30 = 510 (>360) → break
|
|
177
|
+
_make_day("2026-04-07", actual_work_minutes=450),
|
|
178
|
+
_make_open_day("2026-04-08", clock_in="2026-04-08T09:00:00Z"),
|
|
179
|
+
]
|
|
180
|
+
leave_times = get_leave_time(days, DEFAULT_CONFIG, TZ)
|
|
181
|
+
# leave = 09:00 + 510min + 60min = 18:30
|
|
182
|
+
assert leave_times == [
|
|
183
|
+
LeaveTime(includes_break=True, min_time=Duration.parse("18:30")),
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_leave_time_already_clocked_out():
|
|
188
|
+
"""Already clocked out → NoClockInError caught by CLI."""
|
|
189
|
+
from praiselul.errors import NoClockInError
|
|
190
|
+
import pytest
|
|
191
|
+
|
|
192
|
+
days = [
|
|
193
|
+
_make_day(
|
|
194
|
+
"2026-04-08",
|
|
195
|
+
actual_work_minutes=480,
|
|
196
|
+
clock_in="2026-04-08T09:00:00Z",
|
|
197
|
+
clock_out="2026-04-08T18:00:00Z",
|
|
198
|
+
sessions=[{"clockIn": "2026-04-08T09:00:00Z", "clockOut": "2026-04-08T18:00:00Z"}],
|
|
199
|
+
),
|
|
200
|
+
]
|
|
201
|
+
with pytest.raises(NoClockInError):
|
|
202
|
+
get_leave_time(days, DEFAULT_CONFIG, TZ)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_leave_time_part_time():
|
|
206
|
+
"""Part-time config: hours_per_day=6 changes the target."""
|
|
207
|
+
days = [
|
|
208
|
+
# Previous day: exactly 6h → 0 overtime → required_today = 360
|
|
209
|
+
_make_day("2026-04-07", actual_work_minutes=360),
|
|
210
|
+
_make_open_day("2026-04-08", clock_in="2026-04-08T09:00:00Z"),
|
|
211
|
+
]
|
|
212
|
+
leave_times = get_leave_time(days, PART_TIME_CONFIG, TZ)
|
|
213
|
+
# required_today = 360 = 6h exactly → 5-6h range (double window)
|
|
214
|
+
assert leave_times == [
|
|
215
|
+
LeaveTime(includes_break=False, min_time=Duration.parse("15:00"), max_time=Duration.parse("15:00")),
|
|
216
|
+
LeaveTime(includes_break=True, min_time=Duration.parse("16:00")),
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_leave_time_with_timezone():
|
|
221
|
+
"""Clock-in in UTC should be converted to local time for departure calc."""
|
|
222
|
+
jst = ZoneInfo("Asia/Tokyo")
|
|
223
|
+
days = [
|
|
224
|
+
_make_day("2026-04-07", actual_work_minutes=480), # 0 overtime
|
|
225
|
+
# Clock-in at 2026-04-08T00:00:00Z = 09:00 JST
|
|
226
|
+
_make_open_day("2026-04-08", clock_in="2026-04-08T00:00:00Z"),
|
|
227
|
+
]
|
|
228
|
+
leave_times = get_leave_time(days, DEFAULT_CONFIG, jst)
|
|
229
|
+
# required_today = 480 (>360) → break
|
|
230
|
+
# leave = 09:00 JST + 480min + 60min = 18:00
|
|
231
|
+
assert leave_times == [
|
|
232
|
+
LeaveTime(includes_break=True, min_time=Duration.parse("18:00")),
|
|
233
|
+
]
|