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.
@@ -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,2 @@
1
+ [console_scripts]
2
+ praiselul = praiselul.cli:main
@@ -0,0 +1,6 @@
1
+ requests~=2.31
2
+ plotly~=5.18
3
+
4
+ [dev]
5
+ pytest~=8.0
6
+ ruff~=0.4
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]