clockin-tracker 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.
- clockin/__init__.py +8 -0
- clockin/configuration.py +104 -0
- clockin/db.py +409 -0
- clockin/exceptions.py +18 -0
- clockin/main.py +519 -0
- clockin/utils.py +10 -0
- clockin_tracker-0.1.0.dist-info/METADATA +172 -0
- clockin_tracker-0.1.0.dist-info/RECORD +10 -0
- clockin_tracker-0.1.0.dist-info/WHEEL +4 -0
- clockin_tracker-0.1.0.dist-info/entry_points.txt +3 -0
clockin/__init__.py
ADDED
clockin/configuration.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import platformdirs
|
|
6
|
+
|
|
7
|
+
from clockin import APP_NAME, AUTHOR
|
|
8
|
+
|
|
9
|
+
CONFIGURATION = "config.json"
|
|
10
|
+
DEFAULT_CONFIGURATION: dict[str, Any] = {
|
|
11
|
+
"styles": {
|
|
12
|
+
"default": {
|
|
13
|
+
"started": ("green", "✓", "Started session"),
|
|
14
|
+
"stopped": ("red", "■", "Stopped session"),
|
|
15
|
+
"resumed": ("green", "▶", "Resumed session"),
|
|
16
|
+
"stopped_updated": ("red", "■", "Updated stopped session"),
|
|
17
|
+
"canceled": ("red", "×", "Canceled session"),
|
|
18
|
+
"restored": ("green", "↺", "Restored session"),
|
|
19
|
+
"restored_stopped": ("red", "↺", "Restored session"),
|
|
20
|
+
"restored_stopped_note": (
|
|
21
|
+
"red",
|
|
22
|
+
"↳",
|
|
23
|
+
"stopped_at set to the cancellation time",
|
|
24
|
+
),
|
|
25
|
+
"warning": ("yellow", "!"),
|
|
26
|
+
"active_title": ("green", "▶", "Active session"),
|
|
27
|
+
"canceled_title": ("red", "×", "Canceled session"),
|
|
28
|
+
"no_active": ("yellow", "!", "No active session"),
|
|
29
|
+
"report_title": ("yellow", "▤", "Report"),
|
|
30
|
+
"no_sessions": ("yellow", "!", "No sessions to report"),
|
|
31
|
+
},
|
|
32
|
+
"quirky": {
|
|
33
|
+
"started": ("green", "🚀", "Blast off! Session started"),
|
|
34
|
+
"stopped": ("red", "🛑", "Whoa there! Session stopped"),
|
|
35
|
+
"resumed": ("green", "🎬", "And we're back! Session resumed"),
|
|
36
|
+
"stopped_updated": ("red", "📝", "Tweaked that stopped session"),
|
|
37
|
+
"canceled": ("red", "🙅", "Nope! Session canceled"),
|
|
38
|
+
"restored": ("green", "🪄", "Ta-da! Session restored"),
|
|
39
|
+
"restored_stopped": ("red", "🪄", "Poof! Stopped session restored"),
|
|
40
|
+
"restored_stopped_note": (
|
|
41
|
+
"red",
|
|
42
|
+
"🕰️",
|
|
43
|
+
"stopped_at set to the cancellation time",
|
|
44
|
+
),
|
|
45
|
+
"warning": ("yellow", "🤔"),
|
|
46
|
+
"active_title": ("green", "⏱️", "Clock's ticking!"),
|
|
47
|
+
"canceled_title": ("red", "🚫", "Canceled session"),
|
|
48
|
+
"no_active": ("yellow", "😴", "Nothing running... take a break?"),
|
|
49
|
+
"report_title": ("yellow", "📊", "Here's the tea"),
|
|
50
|
+
"no_sessions": ("yellow", "🤷", "Nothing to report yet"),
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
"default_project": None,
|
|
54
|
+
"style": "default",
|
|
55
|
+
"require_project": True,
|
|
56
|
+
}
|
|
57
|
+
INITIAL_CONFIGURATION: dict[str, Any] = {
|
|
58
|
+
"default_project": None,
|
|
59
|
+
"style": "default",
|
|
60
|
+
"require_project": True,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
configuration_path = (
|
|
64
|
+
platformdirs.user_config_path(
|
|
65
|
+
appauthor=AUTHOR,
|
|
66
|
+
appname=APP_NAME,
|
|
67
|
+
ensure_exists=True,
|
|
68
|
+
)
|
|
69
|
+
/ CONFIGURATION
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _write_configuration(configuration: dict[str, Any]) -> None:
|
|
74
|
+
configuration_path.write_text(
|
|
75
|
+
json.dumps(configuration, indent=2) + "\n",
|
|
76
|
+
encoding="utf-8",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _initialize_configuration() -> None:
|
|
81
|
+
_write_configuration(INITIAL_CONFIGURATION)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_user_configuration() -> dict[str, Any]:
|
|
85
|
+
return json.loads(configuration_path.read_text(encoding="utf-8"))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_configuration() -> dict[str, Any]:
|
|
89
|
+
if not configuration_path.exists() or not configuration_path.is_file():
|
|
90
|
+
_initialize_configuration()
|
|
91
|
+
configuration = DEFAULT_CONFIGURATION.copy()
|
|
92
|
+
configuration.update(load_user_configuration())
|
|
93
|
+
return configuration
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def update_configuration(configuration: Mapping[str, Any]) -> dict[str, Any]:
|
|
97
|
+
updated_configuration = load_configuration()
|
|
98
|
+
updated_configuration.update(configuration)
|
|
99
|
+
_write_configuration(updated_configuration)
|
|
100
|
+
return updated_configuration
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def reset_configuration() -> None:
|
|
104
|
+
_initialize_configuration()
|
clockin/db.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from typing import Any
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
import platformdirs
|
|
8
|
+
|
|
9
|
+
from clockin import APP_NAME, AUTHOR
|
|
10
|
+
from clockin.exceptions import (
|
|
11
|
+
ActiveSessionError,
|
|
12
|
+
NoActiveSessionError,
|
|
13
|
+
NoCanceledSessionError,
|
|
14
|
+
NoStoppedSessionError,
|
|
15
|
+
)
|
|
16
|
+
from clockin.utils import to_timestamp
|
|
17
|
+
|
|
18
|
+
SCHEMA_VERSION = "1.0"
|
|
19
|
+
SCHEMA = """
|
|
20
|
+
CREATE TABLE sessions (
|
|
21
|
+
id TEXT PRIMARY KEY UNIQUE,
|
|
22
|
+
project TEXT,
|
|
23
|
+
started_at INTEGER,
|
|
24
|
+
stopped_at INTEGER,
|
|
25
|
+
canceled_at INTEGER
|
|
26
|
+
)
|
|
27
|
+
"""
|
|
28
|
+
DATABASE = f"clockin.v{SCHEMA_VERSION}.db"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Session:
|
|
33
|
+
id: str
|
|
34
|
+
project: str | None
|
|
35
|
+
started_at: datetime
|
|
36
|
+
stopped_at: datetime | None
|
|
37
|
+
canceled_at: datetime | None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Connection:
|
|
41
|
+
def __init__(self, close_on_exit: bool = True):
|
|
42
|
+
user_data_path = platformdirs.user_data_path(
|
|
43
|
+
appauthor=AUTHOR,
|
|
44
|
+
appname=APP_NAME,
|
|
45
|
+
ensure_exists=True,
|
|
46
|
+
)
|
|
47
|
+
database_path = user_data_path / DATABASE
|
|
48
|
+
database_exists = database_path.exists() and database_path.is_file()
|
|
49
|
+
self.__close_on_exit = close_on_exit
|
|
50
|
+
self._connection = sqlite3.connect(database_path)
|
|
51
|
+
if not database_exists:
|
|
52
|
+
self._connection.executescript(SCHEMA)
|
|
53
|
+
|
|
54
|
+
def __enter__(self):
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
58
|
+
if exc_type is None:
|
|
59
|
+
self._connection.commit()
|
|
60
|
+
else:
|
|
61
|
+
self._connection.rollback()
|
|
62
|
+
|
|
63
|
+
if self.__close_on_exit:
|
|
64
|
+
self.close()
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
def _remove_canceled_sessions(self):
|
|
68
|
+
self._connection.execute(
|
|
69
|
+
"""
|
|
70
|
+
DELETE FROM sessions
|
|
71
|
+
WHERE canceled_at IS NOT NULL
|
|
72
|
+
"""
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _active_session_id(self) -> str | None:
|
|
76
|
+
cursor = self._connection.execute(
|
|
77
|
+
"""
|
|
78
|
+
SELECT id
|
|
79
|
+
FROM sessions
|
|
80
|
+
WHERE stopped_at IS NULL
|
|
81
|
+
AND canceled_at IS NULL
|
|
82
|
+
ORDER BY started_at DESC
|
|
83
|
+
LIMIT 1
|
|
84
|
+
"""
|
|
85
|
+
)
|
|
86
|
+
row = cursor.fetchone()
|
|
87
|
+
if row is None:
|
|
88
|
+
return None
|
|
89
|
+
return row[0]
|
|
90
|
+
|
|
91
|
+
def _canceled_session_id(self) -> str | None:
|
|
92
|
+
cursor = self._connection.execute(
|
|
93
|
+
"""
|
|
94
|
+
SELECT id
|
|
95
|
+
FROM sessions
|
|
96
|
+
WHERE canceled_at IS NOT NULL
|
|
97
|
+
AND stopped_at IS NULL
|
|
98
|
+
ORDER BY started_at DESC
|
|
99
|
+
LIMIT 1
|
|
100
|
+
"""
|
|
101
|
+
)
|
|
102
|
+
row = cursor.fetchone()
|
|
103
|
+
if row is None:
|
|
104
|
+
return None
|
|
105
|
+
return row[0]
|
|
106
|
+
|
|
107
|
+
def _stopped_session_id(self) -> str | None:
|
|
108
|
+
cursor = self._connection.execute(
|
|
109
|
+
"""
|
|
110
|
+
SELECT id
|
|
111
|
+
FROM sessions
|
|
112
|
+
WHERE stopped_at IS NOT NULL
|
|
113
|
+
AND canceled_at IS NULL
|
|
114
|
+
ORDER BY stopped_at DESC
|
|
115
|
+
LIMIT 1
|
|
116
|
+
"""
|
|
117
|
+
)
|
|
118
|
+
row = cursor.fetchone()
|
|
119
|
+
if row is None:
|
|
120
|
+
return None
|
|
121
|
+
return row[0]
|
|
122
|
+
|
|
123
|
+
def active_session(self) -> Session | None:
|
|
124
|
+
cursor = self._connection.execute(
|
|
125
|
+
"""
|
|
126
|
+
SELECT id, project, started_at, stopped_at, canceled_at
|
|
127
|
+
FROM sessions
|
|
128
|
+
WHERE stopped_at IS NULL
|
|
129
|
+
AND canceled_at IS NULL
|
|
130
|
+
ORDER BY started_at DESC
|
|
131
|
+
LIMIT 1
|
|
132
|
+
"""
|
|
133
|
+
)
|
|
134
|
+
row = cursor.fetchone()
|
|
135
|
+
if row is None:
|
|
136
|
+
return None
|
|
137
|
+
return Session(
|
|
138
|
+
id=row[0],
|
|
139
|
+
project=row[1],
|
|
140
|
+
started_at=datetime.fromtimestamp(row[2]),
|
|
141
|
+
stopped_at=datetime.fromtimestamp(row[3]) if row[3] is not None else None,
|
|
142
|
+
canceled_at=datetime.fromtimestamp(row[4]) if row[4] is not None else None,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def canceled_session(self) -> Session | None:
|
|
146
|
+
cursor = self._connection.execute(
|
|
147
|
+
"""
|
|
148
|
+
SELECT id, project, started_at, stopped_at, canceled_at
|
|
149
|
+
FROM sessions
|
|
150
|
+
WHERE canceled_at IS NOT NULL
|
|
151
|
+
AND stopped_at IS NULL
|
|
152
|
+
ORDER BY started_at DESC
|
|
153
|
+
LIMIT 1
|
|
154
|
+
"""
|
|
155
|
+
)
|
|
156
|
+
row = cursor.fetchone()
|
|
157
|
+
if row is None:
|
|
158
|
+
return None
|
|
159
|
+
return Session(
|
|
160
|
+
id=row[0],
|
|
161
|
+
project=row[1],
|
|
162
|
+
started_at=datetime.fromtimestamp(row[2]),
|
|
163
|
+
stopped_at=datetime.fromtimestamp(row[3]) if row[3] is not None else None,
|
|
164
|
+
canceled_at=datetime.fromtimestamp(row[4]) if row[4] is not None else None,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def sessions(self, project: str | None = None) -> list[Session]:
|
|
168
|
+
query = """
|
|
169
|
+
SELECT id, project, started_at, stopped_at, canceled_at
|
|
170
|
+
FROM sessions
|
|
171
|
+
WHERE canceled_at IS NULL
|
|
172
|
+
"""
|
|
173
|
+
parameters: tuple[str, ...] = ()
|
|
174
|
+
if project is not None:
|
|
175
|
+
query += " AND project = ?"
|
|
176
|
+
parameters = (project,)
|
|
177
|
+
query += " ORDER BY started_at ASC"
|
|
178
|
+
|
|
179
|
+
cursor = self._connection.execute(query, parameters)
|
|
180
|
+
return [
|
|
181
|
+
Session(
|
|
182
|
+
id=row[0],
|
|
183
|
+
project=row[1],
|
|
184
|
+
started_at=datetime.fromtimestamp(row[2]),
|
|
185
|
+
stopped_at=datetime.fromtimestamp(row[3]) if row[3] is not None else None,
|
|
186
|
+
canceled_at=datetime.fromtimestamp(row[4]) if row[4] is not None else None,
|
|
187
|
+
)
|
|
188
|
+
for row in cursor.fetchall()
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
def close(self):
|
|
192
|
+
self._connection.close()
|
|
193
|
+
|
|
194
|
+
def start_session(
|
|
195
|
+
self,
|
|
196
|
+
project: str | None,
|
|
197
|
+
datetime: datetime | timedelta | None = None,
|
|
198
|
+
):
|
|
199
|
+
if self._active_session_id() is not None:
|
|
200
|
+
raise ActiveSessionError("A session is already running")
|
|
201
|
+
|
|
202
|
+
self._remove_canceled_sessions()
|
|
203
|
+
cursor = self._connection.execute(
|
|
204
|
+
"""
|
|
205
|
+
INSERT INTO sessions (id, project, started_at)
|
|
206
|
+
VALUES (?, ?, ?)
|
|
207
|
+
RETURNING id
|
|
208
|
+
""",
|
|
209
|
+
(str(uuid4()), project, to_timestamp(datetime)),
|
|
210
|
+
)
|
|
211
|
+
return cursor.fetchone()[0]
|
|
212
|
+
|
|
213
|
+
def stop_session(
|
|
214
|
+
self,
|
|
215
|
+
datetime: datetime | timedelta | None = None,
|
|
216
|
+
):
|
|
217
|
+
session_id = self._active_session_id()
|
|
218
|
+
if session_id is None:
|
|
219
|
+
raise NoActiveSessionError("No active session")
|
|
220
|
+
|
|
221
|
+
self._connection.execute(
|
|
222
|
+
"""
|
|
223
|
+
UPDATE sessions
|
|
224
|
+
SET stopped_at = ?
|
|
225
|
+
WHERE id = ?
|
|
226
|
+
""",
|
|
227
|
+
(to_timestamp(datetime), session_id),
|
|
228
|
+
)
|
|
229
|
+
return session_id
|
|
230
|
+
|
|
231
|
+
def resume_session(
|
|
232
|
+
self,
|
|
233
|
+
stop_now: bool = False,
|
|
234
|
+
):
|
|
235
|
+
if self._active_session_id() is not None:
|
|
236
|
+
raise ActiveSessionError("A session is already running")
|
|
237
|
+
|
|
238
|
+
session_id = self._stopped_session_id()
|
|
239
|
+
if session_id is None:
|
|
240
|
+
raise NoStoppedSessionError("No stopped session")
|
|
241
|
+
|
|
242
|
+
if stop_now:
|
|
243
|
+
self._connection.execute(
|
|
244
|
+
"""
|
|
245
|
+
UPDATE sessions
|
|
246
|
+
SET stopped_at = ?
|
|
247
|
+
WHERE id = ?
|
|
248
|
+
""",
|
|
249
|
+
(to_timestamp(None), session_id),
|
|
250
|
+
)
|
|
251
|
+
else:
|
|
252
|
+
self._connection.execute(
|
|
253
|
+
"""
|
|
254
|
+
UPDATE sessions
|
|
255
|
+
SET stopped_at = NULL
|
|
256
|
+
WHERE id = ?
|
|
257
|
+
""",
|
|
258
|
+
(session_id,),
|
|
259
|
+
)
|
|
260
|
+
return session_id
|
|
261
|
+
|
|
262
|
+
def cancel_session(
|
|
263
|
+
self,
|
|
264
|
+
):
|
|
265
|
+
session_id = self._active_session_id()
|
|
266
|
+
if session_id is None:
|
|
267
|
+
raise NoActiveSessionError("No active session")
|
|
268
|
+
|
|
269
|
+
self._connection.execute(
|
|
270
|
+
"""
|
|
271
|
+
UPDATE sessions
|
|
272
|
+
SET canceled_at = ?
|
|
273
|
+
WHERE id = ?
|
|
274
|
+
""",
|
|
275
|
+
(to_timestamp(None), session_id),
|
|
276
|
+
)
|
|
277
|
+
return session_id
|
|
278
|
+
|
|
279
|
+
def restore_session(
|
|
280
|
+
self,
|
|
281
|
+
stopped: bool = False,
|
|
282
|
+
):
|
|
283
|
+
session_id = self._canceled_session_id()
|
|
284
|
+
if session_id is None:
|
|
285
|
+
raise NoCanceledSessionError("No canceled session")
|
|
286
|
+
|
|
287
|
+
if stopped:
|
|
288
|
+
self._connection.execute(
|
|
289
|
+
"""
|
|
290
|
+
UPDATE sessions
|
|
291
|
+
SET stopped_at = canceled_at,
|
|
292
|
+
canceled_at = NULL
|
|
293
|
+
WHERE id = ?
|
|
294
|
+
""",
|
|
295
|
+
(session_id,),
|
|
296
|
+
)
|
|
297
|
+
return session_id
|
|
298
|
+
|
|
299
|
+
self._connection.execute(
|
|
300
|
+
"""
|
|
301
|
+
UPDATE sessions
|
|
302
|
+
SET canceled_at = NULL
|
|
303
|
+
WHERE id = ?
|
|
304
|
+
""",
|
|
305
|
+
(session_id,),
|
|
306
|
+
)
|
|
307
|
+
return session_id
|
|
308
|
+
|
|
309
|
+
def _validate_import_session(
|
|
310
|
+
self,
|
|
311
|
+
session: Any,
|
|
312
|
+
index: int,
|
|
313
|
+
) -> dict[str, Any]:
|
|
314
|
+
if not isinstance(session, dict):
|
|
315
|
+
raise ValueError(f"Session {index}: must be an object")
|
|
316
|
+
|
|
317
|
+
session_id = session.get("id")
|
|
318
|
+
if not isinstance(session_id, str) or session_id == "":
|
|
319
|
+
raise ValueError(f"Session {index}: id must be a non-empty string")
|
|
320
|
+
|
|
321
|
+
project = session.get("project")
|
|
322
|
+
if project is not None and not isinstance(project, str):
|
|
323
|
+
raise ValueError(f"Session {index}: project must be a string or null")
|
|
324
|
+
|
|
325
|
+
started_at = session.get("started_at")
|
|
326
|
+
stopped_at = session.get("stopped_at")
|
|
327
|
+
canceled_at = session.get("canceled_at")
|
|
328
|
+
|
|
329
|
+
if not isinstance(started_at, int) or isinstance(started_at, bool):
|
|
330
|
+
raise ValueError(f"Session {index}: started_at must be an integer timestamp")
|
|
331
|
+
if stopped_at is not None and (
|
|
332
|
+
not isinstance(stopped_at, int) or isinstance(stopped_at, bool)
|
|
333
|
+
):
|
|
334
|
+
raise ValueError(f"Session {index}: stopped_at must be an integer timestamp or null")
|
|
335
|
+
if canceled_at is not None and (
|
|
336
|
+
not isinstance(canceled_at, int) or isinstance(canceled_at, bool)
|
|
337
|
+
):
|
|
338
|
+
raise ValueError(f"Session {index}: canceled_at must be an integer timestamp or null")
|
|
339
|
+
|
|
340
|
+
if stopped_at is not None and canceled_at is not None:
|
|
341
|
+
raise ValueError(f"Session {index}: canceled sessions must have stopped_at set to null")
|
|
342
|
+
if stopped_at is not None and started_at > stopped_at:
|
|
343
|
+
raise ValueError(f"Session {index}: started_at cannot be after stopped_at")
|
|
344
|
+
if canceled_at is not None and started_at > canceled_at:
|
|
345
|
+
raise ValueError(f"Session {index}: started_at cannot be after canceled_at")
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
"id": session_id,
|
|
349
|
+
"project": project,
|
|
350
|
+
"started_at": started_at,
|
|
351
|
+
"stopped_at": stopped_at,
|
|
352
|
+
"canceled_at": canceled_at,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
def import_sessions(self, sessions: list[dict[str, Any]]) -> tuple[int, int]:
|
|
356
|
+
if not isinstance(sessions, list):
|
|
357
|
+
raise ValueError("Import sessions must be a list")
|
|
358
|
+
|
|
359
|
+
sessions = [
|
|
360
|
+
self._validate_import_session(session, index)
|
|
361
|
+
for index, session in enumerate(sessions, start=1)
|
|
362
|
+
]
|
|
363
|
+
|
|
364
|
+
active_count = sum(
|
|
365
|
+
session.get("stopped_at") is None and session.get("canceled_at") is None
|
|
366
|
+
for session in sessions
|
|
367
|
+
)
|
|
368
|
+
canceled_count = sum(
|
|
369
|
+
session.get("canceled_at") is not None for session in sessions
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
if active_count > 1:
|
|
373
|
+
raise ActiveSessionError("Import contains more than one active session")
|
|
374
|
+
if active_count == 1 and self._active_session_id() is not None:
|
|
375
|
+
raise ActiveSessionError("A session is already running")
|
|
376
|
+
if canceled_count > 1:
|
|
377
|
+
raise NoCanceledSessionError(
|
|
378
|
+
"Import contains more than one canceled session"
|
|
379
|
+
)
|
|
380
|
+
if canceled_count == 1 and self._canceled_session_id() is not None:
|
|
381
|
+
raise NoCanceledSessionError("A canceled session already exists")
|
|
382
|
+
|
|
383
|
+
imported = 0
|
|
384
|
+
skipped = 0
|
|
385
|
+
for session in sessions:
|
|
386
|
+
cursor = self._connection.execute(
|
|
387
|
+
"""
|
|
388
|
+
INSERT OR IGNORE INTO sessions (
|
|
389
|
+
id,
|
|
390
|
+
project,
|
|
391
|
+
started_at,
|
|
392
|
+
stopped_at,
|
|
393
|
+
canceled_at
|
|
394
|
+
)
|
|
395
|
+
VALUES (?, ?, ?, ?, ?)
|
|
396
|
+
""",
|
|
397
|
+
(
|
|
398
|
+
session["id"],
|
|
399
|
+
session.get("project"),
|
|
400
|
+
session["started_at"],
|
|
401
|
+
session.get("stopped_at"),
|
|
402
|
+
session.get("canceled_at"),
|
|
403
|
+
),
|
|
404
|
+
)
|
|
405
|
+
if cursor.rowcount == 0:
|
|
406
|
+
skipped += 1
|
|
407
|
+
else:
|
|
408
|
+
imported += 1
|
|
409
|
+
return imported, skipped
|
clockin/exceptions.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class ClockinError(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ActiveSessionError(ClockinError):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NoActiveSessionError(ClockinError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NoCanceledSessionError(ClockinError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NoStoppedSessionError(ClockinError):
|
|
18
|
+
pass
|
clockin/main.py
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import shlex
|
|
4
|
+
import subprocess
|
|
5
|
+
from datetime import date, datetime, time, timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console, Group
|
|
12
|
+
from rich.json import JSON
|
|
13
|
+
from rich.markup import escape
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
from clockin.configuration import (
|
|
19
|
+
DEFAULT_CONFIGURATION,
|
|
20
|
+
configuration_path,
|
|
21
|
+
load_configuration,
|
|
22
|
+
load_user_configuration,
|
|
23
|
+
reset_configuration,
|
|
24
|
+
)
|
|
25
|
+
from clockin.db import Connection
|
|
26
|
+
from clockin.exceptions import (
|
|
27
|
+
ActiveSessionError,
|
|
28
|
+
NoActiveSessionError,
|
|
29
|
+
NoCanceledSessionError,
|
|
30
|
+
NoStoppedSessionError,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
app = typer.Typer()
|
|
34
|
+
config_app = typer.Typer()
|
|
35
|
+
app.add_typer(config_app, name="config")
|
|
36
|
+
console = Console()
|
|
37
|
+
error_console = Console(stderr=True)
|
|
38
|
+
configuration = load_configuration()
|
|
39
|
+
style = configuration["styles"][configuration["style"]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _print_session_feedback(key: str, session_id: str):
|
|
43
|
+
color, glyph, message = style[key]
|
|
44
|
+
console.print(
|
|
45
|
+
f"[bold {color}]{glyph} {message}[/bold {color}] [dim]{session_id}[/dim]"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _print_warning(error: Exception):
|
|
50
|
+
color, glyph = style["warning"]
|
|
51
|
+
error_console.print(f"[bold {color}]{glyph} {escape(str(error))}[/bold {color}]")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parse_import_timestamp(value: Any, field: str, index: int) -> int | None:
|
|
55
|
+
if value is None:
|
|
56
|
+
return None
|
|
57
|
+
if isinstance(value, bool):
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"Session {index}: {field} must be a timestamp or ISO datetime"
|
|
60
|
+
)
|
|
61
|
+
if isinstance(value, int | float):
|
|
62
|
+
return int(value)
|
|
63
|
+
if isinstance(value, str):
|
|
64
|
+
value = value.strip()
|
|
65
|
+
if not value:
|
|
66
|
+
raise ValueError(f"Session {index}: {field} cannot be empty")
|
|
67
|
+
try:
|
|
68
|
+
return int(float(value))
|
|
69
|
+
except ValueError:
|
|
70
|
+
pass
|
|
71
|
+
try:
|
|
72
|
+
return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp())
|
|
73
|
+
except ValueError as error:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"Session {index}: {field} must be a timestamp or ISO datetime"
|
|
76
|
+
) from error
|
|
77
|
+
raise ValueError(f"Session {index}: {field} must be a timestamp or ISO datetime")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _load_import_sessions(json_file: Path) -> list[dict[str, Any]]:
|
|
81
|
+
payload = json.loads(json_file.read_text(encoding="utf-8"))
|
|
82
|
+
if isinstance(payload, dict):
|
|
83
|
+
payload = payload.get("sessions")
|
|
84
|
+
if not isinstance(payload, list):
|
|
85
|
+
raise ValueError(
|
|
86
|
+
"Import file must contain a JSON array or an object with sessions"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
sessions = []
|
|
90
|
+
for index, session in enumerate(payload, start=1):
|
|
91
|
+
if not isinstance(session, dict):
|
|
92
|
+
raise ValueError(f"Session {index}: must be an object")
|
|
93
|
+
|
|
94
|
+
started_at = _parse_import_timestamp(
|
|
95
|
+
session.get("started_at"), "started_at", index
|
|
96
|
+
)
|
|
97
|
+
if started_at is None:
|
|
98
|
+
raise ValueError(f"Session {index}: started_at is required")
|
|
99
|
+
stopped_at = _parse_import_timestamp(
|
|
100
|
+
session.get("stopped_at"), "stopped_at", index
|
|
101
|
+
)
|
|
102
|
+
canceled_at = _parse_import_timestamp(
|
|
103
|
+
session.get("canceled_at"), "canceled_at", index
|
|
104
|
+
)
|
|
105
|
+
if stopped_at is not None and canceled_at is not None:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"Session {index}: canceled sessions must have stopped_at set to null"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
session_id = session.get("id") or str(uuid4())
|
|
111
|
+
if not isinstance(session_id, str):
|
|
112
|
+
raise ValueError(f"Session {index}: id must be a string")
|
|
113
|
+
project = session.get("project")
|
|
114
|
+
if project is not None and not isinstance(project, str):
|
|
115
|
+
raise ValueError(f"Session {index}: project must be a string or null")
|
|
116
|
+
|
|
117
|
+
sessions.append(
|
|
118
|
+
{
|
|
119
|
+
"id": session_id,
|
|
120
|
+
"project": project,
|
|
121
|
+
"started_at": started_at,
|
|
122
|
+
"stopped_at": stopped_at,
|
|
123
|
+
"canceled_at": canceled_at,
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
return sessions
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@config_app.command("show")
|
|
130
|
+
def config_show(
|
|
131
|
+
show_full: bool = typer.Option(False, "--full"),
|
|
132
|
+
show_default: bool = typer.Option(False, "--default"),
|
|
133
|
+
):
|
|
134
|
+
configuration = load_user_configuration()
|
|
135
|
+
if show_full:
|
|
136
|
+
configuration = load_configuration()
|
|
137
|
+
if show_default:
|
|
138
|
+
configuration = DEFAULT_CONFIGURATION
|
|
139
|
+
console.print(JSON(json.dumps(configuration, indent=2)))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@config_app.command("edit")
|
|
143
|
+
def config_edit():
|
|
144
|
+
load_configuration()
|
|
145
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
|
|
146
|
+
if editor is None:
|
|
147
|
+
_print_warning(RuntimeError("Set VISUAL or EDITOR to edit configuration"))
|
|
148
|
+
raise typer.Exit(1)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
result = subprocess.run(
|
|
152
|
+
[*shlex.split(editor), str(configuration_path)],
|
|
153
|
+
check=False,
|
|
154
|
+
)
|
|
155
|
+
except OSError as error:
|
|
156
|
+
_print_warning(error)
|
|
157
|
+
raise typer.Exit(1) from error
|
|
158
|
+
|
|
159
|
+
if result.returncode != 0:
|
|
160
|
+
_print_warning(RuntimeError(f"Editor exited with {result.returncode}"))
|
|
161
|
+
raise typer.Exit(result.returncode)
|
|
162
|
+
|
|
163
|
+
console.print(
|
|
164
|
+
f"[bold green]✓ Edited configuration[/bold green] [white dim]{configuration_path}[/white dim]"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@config_app.command("reset")
|
|
169
|
+
def config_reset():
|
|
170
|
+
reset_configuration()
|
|
171
|
+
console.print(
|
|
172
|
+
f"[bold green]✓ Reset configuration[/bold green] [white dim]{configuration_path}[/white dim]"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@app.command()
|
|
177
|
+
def start(project: str | None = typer.Argument(None)):
|
|
178
|
+
try:
|
|
179
|
+
with Connection() as connection:
|
|
180
|
+
session_id = connection.start_session(project)
|
|
181
|
+
except ActiveSessionError as error:
|
|
182
|
+
_print_warning(error)
|
|
183
|
+
raise typer.Exit(1) from error
|
|
184
|
+
|
|
185
|
+
_print_session_feedback("started", session_id)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@app.command()
|
|
189
|
+
def stop():
|
|
190
|
+
try:
|
|
191
|
+
with Connection() as connection:
|
|
192
|
+
session_id = connection.stop_session()
|
|
193
|
+
except NoActiveSessionError as error:
|
|
194
|
+
_print_warning(error)
|
|
195
|
+
raise typer.Exit(1) from error
|
|
196
|
+
|
|
197
|
+
_print_session_feedback("stopped", session_id)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.command()
|
|
201
|
+
def resume(stop_now: bool = typer.Option(False, "--stop-now")):
|
|
202
|
+
try:
|
|
203
|
+
with Connection() as connection:
|
|
204
|
+
session_id = connection.resume_session(stop_now=stop_now)
|
|
205
|
+
except (ActiveSessionError, NoStoppedSessionError) as error:
|
|
206
|
+
_print_warning(error)
|
|
207
|
+
raise typer.Exit(1) from error
|
|
208
|
+
|
|
209
|
+
if stop_now:
|
|
210
|
+
_print_session_feedback("stopped_updated", session_id)
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
_print_session_feedback("resumed", session_id)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@app.command()
|
|
217
|
+
def cancel():
|
|
218
|
+
try:
|
|
219
|
+
with Connection() as connection:
|
|
220
|
+
session_id = connection.cancel_session()
|
|
221
|
+
except NoActiveSessionError as error:
|
|
222
|
+
_print_warning(error)
|
|
223
|
+
raise typer.Exit(1) from error
|
|
224
|
+
|
|
225
|
+
_print_session_feedback("canceled", session_id)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@app.command()
|
|
229
|
+
def restore(stopped: bool = typer.Option(False, "--stopped")):
|
|
230
|
+
try:
|
|
231
|
+
with Connection() as connection:
|
|
232
|
+
session_id = connection.restore_session(stopped=stopped)
|
|
233
|
+
except (ActiveSessionError, NoCanceledSessionError) as error:
|
|
234
|
+
_print_warning(error)
|
|
235
|
+
raise typer.Exit(1) from error
|
|
236
|
+
|
|
237
|
+
if stopped:
|
|
238
|
+
color, glyph, message = style["restored_stopped"]
|
|
239
|
+
note_color, note_glyph, note = style["restored_stopped_note"]
|
|
240
|
+
console.print(
|
|
241
|
+
f"[bold {color}]{glyph} {message}[/bold {color}] [dim]{session_id}[/dim]\n"
|
|
242
|
+
f"[{note_color}]{note_glyph}[/{note_color}] [dim]{note}[/dim]"
|
|
243
|
+
)
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
_print_session_feedback("restored", session_id)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@app.command("import")
|
|
250
|
+
def import_sessions(json_file: Path = typer.Argument(..., exists=True, dir_okay=False)):
|
|
251
|
+
try:
|
|
252
|
+
sessions = _load_import_sessions(json_file)
|
|
253
|
+
with Connection() as connection:
|
|
254
|
+
imported, skipped = connection.import_sessions(sessions)
|
|
255
|
+
active_session = connection.active_session()
|
|
256
|
+
canceled_session = connection.canceled_session()
|
|
257
|
+
except (
|
|
258
|
+
OSError,
|
|
259
|
+
ValueError,
|
|
260
|
+
ActiveSessionError,
|
|
261
|
+
NoCanceledSessionError,
|
|
262
|
+
) as error:
|
|
263
|
+
_print_warning(error)
|
|
264
|
+
raise typer.Exit(1) from error
|
|
265
|
+
|
|
266
|
+
color, glyph = "yellow", "!"
|
|
267
|
+
if active_session is not None:
|
|
268
|
+
color, glyph = "green", "✓"
|
|
269
|
+
elif canceled_session is not None:
|
|
270
|
+
color, glyph = "red", "×"
|
|
271
|
+
|
|
272
|
+
console.print(
|
|
273
|
+
f"[bold {color}]{glyph} Imported {imported} session{'s' if imported != 1 else ''}[/bold {color}]"
|
|
274
|
+
f" [dim]({skipped} skipped)[/dim]"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@app.command()
|
|
279
|
+
def status():
|
|
280
|
+
with Connection() as connection:
|
|
281
|
+
session = connection.active_session()
|
|
282
|
+
canceled_session = connection.canceled_session()
|
|
283
|
+
|
|
284
|
+
if session is None:
|
|
285
|
+
if canceled_session is not None:
|
|
286
|
+
title_color, title_glyph, title = style["canceled_title"]
|
|
287
|
+
table = Table.grid(padding=(0, 2))
|
|
288
|
+
table.add_column(style="bold cyan")
|
|
289
|
+
table.add_column()
|
|
290
|
+
table.add_row("Session", f"[dim]{canceled_session.id}[/dim]")
|
|
291
|
+
if canceled_session.project is not None:
|
|
292
|
+
table.add_row("Project", escape(canceled_session.project))
|
|
293
|
+
table.add_row("Started", f"{canceled_session.started_at:%Y-%m-%d %H:%M:%S}")
|
|
294
|
+
if canceled_session.canceled_at is not None:
|
|
295
|
+
table.add_row(
|
|
296
|
+
"Canceled",
|
|
297
|
+
f"[bold red]{canceled_session.canceled_at:%Y-%m-%d %H:%M:%S}[/bold red]",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
console.print(
|
|
301
|
+
Panel(
|
|
302
|
+
table,
|
|
303
|
+
title=f"[bold {title_color}]{title_glyph} {title}[/bold {title_color}]",
|
|
304
|
+
border_style=title_color,
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
color, glyph, message = style["no_active"]
|
|
310
|
+
console.print(f"[bold {color}]{glyph} {message}[/bold {color}]")
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
elapsed = datetime.now() - session.started_at
|
|
314
|
+
|
|
315
|
+
table = Table.grid(padding=(0, 2))
|
|
316
|
+
table.add_column(style="bold cyan")
|
|
317
|
+
table.add_column()
|
|
318
|
+
table.add_row("Session", f"[dim]{session.id}[/dim]")
|
|
319
|
+
if session.project is not None:
|
|
320
|
+
table.add_row("Project", escape(session.project))
|
|
321
|
+
table.add_row("Started", f"{session.started_at:%Y-%m-%d %H:%M:%S}")
|
|
322
|
+
table.add_row("Elapsed", f"[bold green]{_format_duration(elapsed)}[/bold green]")
|
|
323
|
+
|
|
324
|
+
title_color, title_glyph, title = style["active_title"]
|
|
325
|
+
console.print(
|
|
326
|
+
Panel(
|
|
327
|
+
table,
|
|
328
|
+
title=f"[bold {title_color}]{title_glyph} {title}[/bold {title_color}]",
|
|
329
|
+
border_style=title_color,
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
WEEKDAY_LABELS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
|
335
|
+
WEEKDAY_NAMES = (
|
|
336
|
+
"Monday",
|
|
337
|
+
"Tuesday",
|
|
338
|
+
"Wednesday",
|
|
339
|
+
"Thursday",
|
|
340
|
+
"Friday",
|
|
341
|
+
"Saturday",
|
|
342
|
+
"Sunday",
|
|
343
|
+
)
|
|
344
|
+
HEATMAP_EMPTY_COLOR = "grey27"
|
|
345
|
+
HEATMAP_GLYPH = "■"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _format_duration(elapsed: timedelta) -> str:
|
|
349
|
+
total_seconds = int(elapsed.total_seconds())
|
|
350
|
+
hours, remainder = divmod(total_seconds, 3600)
|
|
351
|
+
minutes, seconds = divmod(remainder, 60)
|
|
352
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _split_by_day(
|
|
356
|
+
started_at: datetime, ended_at: datetime
|
|
357
|
+
) -> list[tuple[date, timedelta]]:
|
|
358
|
+
segments = []
|
|
359
|
+
current = started_at
|
|
360
|
+
while current < ended_at:
|
|
361
|
+
next_midnight = datetime.combine(current.date() + timedelta(days=1), time.min)
|
|
362
|
+
segment_end = min(ended_at, next_midnight)
|
|
363
|
+
segments.append((current.date(), segment_end - current))
|
|
364
|
+
current = segment_end
|
|
365
|
+
return segments
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _green_shade(ratio: float) -> str:
|
|
369
|
+
ratio = max(0.0, min(ratio, 1.0))
|
|
370
|
+
green = round(70 + (255 - 70) * ratio)
|
|
371
|
+
return f"#00{green:02x}00"
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _heatmap_color(seconds: float, max_seconds: float) -> str:
|
|
375
|
+
if seconds <= 0 or max_seconds <= 0:
|
|
376
|
+
return HEATMAP_EMPTY_COLOR
|
|
377
|
+
return _green_shade(seconds / max_seconds)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _render_heatmap(daily_totals: dict[date, timedelta], weeks: int) -> Text:
|
|
381
|
+
end_date = date.today()
|
|
382
|
+
start_date = end_date - timedelta(days=weeks * 7 - 1)
|
|
383
|
+
grid = [
|
|
384
|
+
[start_date + timedelta(days=column * 7 + row) for row in range(7)]
|
|
385
|
+
for column in range(weeks)
|
|
386
|
+
]
|
|
387
|
+
max_seconds = max(
|
|
388
|
+
(
|
|
389
|
+
daily_totals.get(day, timedelta()).total_seconds()
|
|
390
|
+
for column in grid
|
|
391
|
+
for day in column
|
|
392
|
+
),
|
|
393
|
+
default=0.0,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
heatmap = Text()
|
|
397
|
+
for row in range(7):
|
|
398
|
+
heatmap.append(f"{WEEKDAY_LABELS[grid[0][row].weekday()]} ", style="dim")
|
|
399
|
+
for column in grid:
|
|
400
|
+
seconds = daily_totals.get(column[row], timedelta()).total_seconds()
|
|
401
|
+
heatmap.append(
|
|
402
|
+
f"{HEATMAP_GLYPH} ", style=_heatmap_color(seconds, max_seconds)
|
|
403
|
+
)
|
|
404
|
+
heatmap.append("\n")
|
|
405
|
+
|
|
406
|
+
heatmap.append(" Less ", style="dim")
|
|
407
|
+
heatmap.append(f"{HEATMAP_GLYPH} ", style=HEATMAP_EMPTY_COLOR)
|
|
408
|
+
for ratio in (0.25, 0.5, 0.75, 1.0):
|
|
409
|
+
heatmap.append(f"{HEATMAP_GLYPH} ", style=_green_shade(ratio))
|
|
410
|
+
heatmap.append("More", style="dim")
|
|
411
|
+
return heatmap
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@app.command()
|
|
415
|
+
def report(
|
|
416
|
+
project: str | None = typer.Option(None, "--project"),
|
|
417
|
+
weeks: int = typer.Option(20, "--weeks"),
|
|
418
|
+
):
|
|
419
|
+
weeks = max(weeks, 1)
|
|
420
|
+
|
|
421
|
+
with Connection() as connection:
|
|
422
|
+
sessions = connection.sessions(project=project)
|
|
423
|
+
|
|
424
|
+
if not sessions:
|
|
425
|
+
color, glyph, message = style["no_sessions"]
|
|
426
|
+
console.print(f"[bold {color}]{glyph} {message}[/bold {color}]")
|
|
427
|
+
return
|
|
428
|
+
|
|
429
|
+
now = datetime.now()
|
|
430
|
+
project_totals: dict[str | None, tuple[int, timedelta]] = {}
|
|
431
|
+
daily_totals: dict[date, timedelta] = {}
|
|
432
|
+
weekday_totals: dict[int, timedelta] = {}
|
|
433
|
+
hour_totals: dict[int, timedelta] = {}
|
|
434
|
+
|
|
435
|
+
for session in sessions:
|
|
436
|
+
effective_end = session.stopped_at or now
|
|
437
|
+
elapsed = effective_end - session.started_at
|
|
438
|
+
|
|
439
|
+
count, total = project_totals.get(session.project, (0, timedelta()))
|
|
440
|
+
project_totals[session.project] = (count + 1, total + elapsed)
|
|
441
|
+
|
|
442
|
+
hour_totals[session.started_at.hour] = (
|
|
443
|
+
hour_totals.get(session.started_at.hour, timedelta()) + elapsed
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
for day, duration in _split_by_day(session.started_at, effective_end):
|
|
447
|
+
daily_totals[day] = daily_totals.get(day, timedelta()) + duration
|
|
448
|
+
weekday_totals[day.weekday()] = (
|
|
449
|
+
weekday_totals.get(day.weekday(), timedelta()) + duration
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
grand_count = sum(count for count, _ in project_totals.values())
|
|
453
|
+
grand_total = sum((total for _, total in project_totals.values()), timedelta())
|
|
454
|
+
|
|
455
|
+
table = Table.grid(padding=(0, 2))
|
|
456
|
+
table.add_column(style="bold cyan")
|
|
457
|
+
table.add_column(justify="right")
|
|
458
|
+
table.add_column(justify="right")
|
|
459
|
+
table.add_column(justify="right")
|
|
460
|
+
table.add_column(justify="right")
|
|
461
|
+
table.add_row(
|
|
462
|
+
"[dim]Project[/dim]",
|
|
463
|
+
"[dim]Sessions[/dim]",
|
|
464
|
+
"[dim]Total[/dim]",
|
|
465
|
+
"[dim]Avg[/dim]",
|
|
466
|
+
"[dim]%[/dim]",
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
for session_project, (count, total) in sorted(
|
|
470
|
+
project_totals.items(), key=lambda item: item[1][1], reverse=True
|
|
471
|
+
):
|
|
472
|
+
percentage = total / grand_total * 100 if grand_total else 0.0
|
|
473
|
+
label = (
|
|
474
|
+
escape(session_project)
|
|
475
|
+
if session_project is not None
|
|
476
|
+
else "[dim](no project)[/dim]"
|
|
477
|
+
)
|
|
478
|
+
table.add_row(
|
|
479
|
+
label,
|
|
480
|
+
f"{count} session{'s' if count != 1 else ''}",
|
|
481
|
+
_format_duration(total),
|
|
482
|
+
_format_duration(total / count),
|
|
483
|
+
f"{percentage:.1f}%",
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
table.add_row(
|
|
487
|
+
"[bold]Total[/bold]",
|
|
488
|
+
f"[bold]{grand_count} session{'s' if grand_count != 1 else ''}[/bold]",
|
|
489
|
+
f"[bold]{_format_duration(grand_total)}[/bold]",
|
|
490
|
+
f"[bold]{_format_duration(grand_total / grand_count)}[/bold]",
|
|
491
|
+
"[bold]100.0%[/bold]",
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
insights = Text()
|
|
495
|
+
if weekday_totals:
|
|
496
|
+
busiest_weekday = max(weekday_totals, key=weekday_totals.get)
|
|
497
|
+
insights.append("Busiest day ", style="bold cyan")
|
|
498
|
+
insights.append(
|
|
499
|
+
f"{WEEKDAY_NAMES[busiest_weekday]} · "
|
|
500
|
+
f"{_format_duration(weekday_totals[busiest_weekday])}\n"
|
|
501
|
+
)
|
|
502
|
+
if hour_totals:
|
|
503
|
+
busiest_hour = max(hour_totals, key=hour_totals.get)
|
|
504
|
+
insights.append("Busiest hour ", style="bold cyan")
|
|
505
|
+
insights.append(
|
|
506
|
+
f"{busiest_hour:02d}:00-{(busiest_hour + 1) % 24:02d}:00 · "
|
|
507
|
+
f"{_format_duration(hour_totals[busiest_hour])}"
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
heatmap = _render_heatmap(daily_totals, weeks)
|
|
511
|
+
|
|
512
|
+
title_color, title_glyph, title = style["report_title"]
|
|
513
|
+
console.print(
|
|
514
|
+
Panel(
|
|
515
|
+
Group(table, "", insights, "", heatmap),
|
|
516
|
+
title=f"[bold {title_color}]{title_glyph} {title}[/bold {title_color}]",
|
|
517
|
+
border_style=title_color,
|
|
518
|
+
)
|
|
519
|
+
)
|
clockin/utils.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def to_timestamp(value: datetime | timedelta | None) -> int:
|
|
5
|
+
if value is None:
|
|
6
|
+
return int(datetime.now().timestamp())
|
|
7
|
+
if isinstance(value, datetime):
|
|
8
|
+
return int(value.timestamp())
|
|
9
|
+
if isinstance(value, timedelta):
|
|
10
|
+
return int((datetime.now() + value).timestamp())
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: clockin-tracker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Markos Narinian
|
|
6
|
+
Author-email: Markos Narinian <manarinian@gmail.com>
|
|
7
|
+
Requires-Dist: platformdirs>=4.10.0
|
|
8
|
+
Requires-Dist: rich>=15.0.0
|
|
9
|
+
Requires-Dist: typer>=0.26.8
|
|
10
|
+
Requires-Python: >=3.13
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# clockin
|
|
14
|
+
|
|
15
|
+
[](https://github.com/markosnarinian/clockin/actions/workflows/python-publish.yml)
|
|
16
|
+
|
|
17
|
+
**Time to (c)lock in!**
|
|
18
|
+
|
|
19
|
+
Welcome to a micro-project of mine!
|
|
20
|
+
I've wanted to build a time tracker for a while. It annoys me that every time tracker I've tried is either built for professionals/freelancers, or, in the case of TUI time trackers, built for devs tracking tasks (who cares!). I do a loooot of things and have backlogs full of hundreds of ideas. This is why I want to track time spent working on my projects vs working out/relaxing/reading vs sleeping vs wasting time, along with % time allocation by activity type and by project. And who doesn't like an activity heatmap?
|
|
21
|
+
It took me this long to build because I wanted to create proper apps for all platforms, but I decided I need this now — so I put it under micro-projects on my priorities list and built it as a terminal application (full TUI coming, relatively soon!).
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
clockin is published on PyPI as `clockin-tracker`.
|
|
26
|
+
|
|
27
|
+
**uv (recommended)**
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
# Install it as a standalone CLI tool
|
|
31
|
+
uv tool install clockin-tracker
|
|
32
|
+
|
|
33
|
+
# ...or run it without installing anything
|
|
34
|
+
uvx clockin-tracker clockin --help
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**pipx**
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
pipx install clockin-tracker
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**From source (for development/testing)**
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
git clone https://github.com/markosnarinian/clockin
|
|
47
|
+
cd clockin
|
|
48
|
+
uv sync
|
|
49
|
+
uv run clockin --help
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
$ clockin start clockin
|
|
56
|
+
✓ Started session 3f9a1c2e-...
|
|
57
|
+
|
|
58
|
+
$ clockin status
|
|
59
|
+
╭─ ▶ Active session ─────────────╮
|
|
60
|
+
│ Session 3f9a1c2e-... │
|
|
61
|
+
│ Project clockin │
|
|
62
|
+
│ Started 2026-07-08 09:00:00 │
|
|
63
|
+
│ Elapsed 00:12:34 │
|
|
64
|
+
╰────────────────────────────────╯
|
|
65
|
+
|
|
66
|
+
$ clockin stop
|
|
67
|
+
■ Stopped session 3f9a1c2e-...
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Prefer emoji over checkmarks? Switch to the built-in `quirky` style — see [Configuration](#configuration).
|
|
71
|
+
|
|
72
|
+
## Commands
|
|
73
|
+
|
|
74
|
+
| Command | Description |
|
|
75
|
+
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
76
|
+
| `clockin start [PROJECT]` | Start a new session, optionally tagged with a project name. Fails if a session is already running. |
|
|
77
|
+
| `clockin stop` | Stop the active session. |
|
|
78
|
+
| `clockin status` | Show the active session (with elapsed time) or a pending canceled session. |
|
|
79
|
+
| `clockin resume [--stop-now]` | Resume the most recently stopped session. With `--stop-now`, immediately re-stops it (e.g. to backdate a correction). |
|
|
80
|
+
| `clockin cancel` | Cancel the active session instead of stopping it. |
|
|
81
|
+
| `clockin restore [--stopped]` | Restore the pending canceled session. With `--stopped`, restore it as stopped instead of active. |
|
|
82
|
+
| `clockin import FILE` | Bulk-import sessions from a JSON file. See [Importing sessions](#importing-sessions). |
|
|
83
|
+
| `clockin config show [--full] [--default]` | Print your configuration, the full merged configuration, or the built-in defaults. |
|
|
84
|
+
| `clockin config edit` | Open the config file in `$VISUAL`/`$EDITOR`. |
|
|
85
|
+
| `clockin config reset` | Reset configuration to defaults. |
|
|
86
|
+
|
|
87
|
+
## Session lifecycle
|
|
88
|
+
|
|
89
|
+
At most one session can be active at a time, and at most one canceled session can be pending restoration at a time:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
start ──▶ active ──stop──▶ stopped ──resume──▶ active
|
|
93
|
+
│
|
|
94
|
+
cancel
|
|
95
|
+
│
|
|
96
|
+
▼
|
|
97
|
+
canceled ──restore──▶ active
|
|
98
|
+
│
|
|
99
|
+
└──restore --stopped──▶ stopped
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Starting a new session discards any pending canceled session, so restore it first if you want to keep it.
|
|
103
|
+
|
|
104
|
+
## Importing sessions
|
|
105
|
+
|
|
106
|
+
`clockin import FILE` reads a JSON array of sessions, or an object with a `sessions` array (see [sample.json](sample.json)):
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"sessions": [
|
|
111
|
+
{
|
|
112
|
+
"id": "work-session-001",
|
|
113
|
+
"project": "clockin",
|
|
114
|
+
"started_at": "2026-01-01T09:00:00Z",
|
|
115
|
+
"stopped_at": "2026-01-01T10:30:00Z",
|
|
116
|
+
"canceled_at": null
|
|
117
|
+
}
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- `started_at` is required; `stopped_at`/`canceled_at` are optional but mutually exclusive.
|
|
123
|
+
- Timestamps accept a Unix timestamp or an ISO-8601 datetime string.
|
|
124
|
+
- `id` is optional (a UUID is generated if omitted); `project` is optional.
|
|
125
|
+
- At most one imported session may be active, and at most one may be canceled — and only if you don't already have one of that kind.
|
|
126
|
+
- Sessions with an `id` that already exists are skipped, so imports are safe to re-run.
|
|
127
|
+
|
|
128
|
+
### Importing from other software
|
|
129
|
+
|
|
130
|
+
When moving history from another time tracker, calendar, spreadsheet, or database, convert the source export into clockin's import JSON first. Use stable IDs from the original software when possible, optionally prefixed with the source name (for example, `toggl-123456`), so you can safely re-run the same import without duplicating sessions.
|
|
131
|
+
|
|
132
|
+
Map each source entry to one clockin session state:
|
|
133
|
+
|
|
134
|
+
- completed entries become stopped sessions with `started_at` and `stopped_at` set;
|
|
135
|
+
- one open/running entry may become an active session with both `stopped_at` and `canceled_at` set to `null`;
|
|
136
|
+
- deleted or voided entries should usually be skipped unless you intentionally want one represented as the single pending canceled session.
|
|
137
|
+
|
|
138
|
+
Prefer ISO-8601 timestamps with explicit timezones (`2026-01-01T09:00:00-05:00` or `2026-01-01T14:00:00Z`) when converting from software that exports local times. If the source only has a duration, compute `stopped_at` from `started_at + duration`.
|
|
139
|
+
|
|
140
|
+
Agents helping with imports should use the project skill in [`skills/importing-sessions/SKILL.md`](skills/importing-sessions/SKILL.md) for the full conversion workflow, validation checklist, and safety guidance before running `clockin import`.
|
|
141
|
+
|
|
142
|
+
## Configuration
|
|
143
|
+
|
|
144
|
+
clockin stores its config as JSON in a per-OS config directory. Run `clockin config edit` to open it in `$VISUAL`/`$EDITOR`, or `clockin config reset` to restore the defaults.
|
|
145
|
+
|
|
146
|
+
| Key | Default | Description |
|
|
147
|
+
| ----------------- | ----------- | ---------------------------------------------------------------------------------------------- |
|
|
148
|
+
| `style` | `"default"` | Which output style to use: `"default"` (plain glyphs) or `"quirky"` (emoji, more personality). |
|
|
149
|
+
| `default_project` | `null` | Reserved for a future default project on `clockin start`. Not enforced yet. |
|
|
150
|
+
| `require_project` | `true` | Reserved for making `clockin start` require a project. Not enforced yet. |
|
|
151
|
+
|
|
152
|
+
Run `clockin config show --default` to see the full built-in configuration, including the `styles` definitions themselves (colors, glyphs, and messages per state) if you want to customize them.
|
|
153
|
+
|
|
154
|
+
## Data storage
|
|
155
|
+
|
|
156
|
+
Sessions are stored in a SQLite database in a per-OS data directory, versioned by schema (currently `clockin.v1.0.db`). Back it up like any other file — there's no built-in export command yet, but `clockin import` can be used in reverse by reading the database directly with any SQLite client.
|
|
157
|
+
|
|
158
|
+
## Development
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
git clone https://github.com/markosnarinian/clockin
|
|
162
|
+
cd clockin
|
|
163
|
+
uv sync
|
|
164
|
+
uv run pytest
|
|
165
|
+
uv run ruff check .
|
|
166
|
+
uv run ruff format .
|
|
167
|
+
uv run clockin --help
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
[AGPL-3.0](LICENSE)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
clockin/__init__.py,sha256=V_dR1LcNzya-t5vSHLiwz1_LUvyloTdMrnWIuAlC64A,113
|
|
2
|
+
clockin/configuration.py,sha256=VVfZsXjAPl2NJ61PFldfAoq9Uubqq9_uEEiXCJhzvts,3714
|
|
3
|
+
clockin/db.py,sha256=vsz2325XDpMoN-8jOzwIMnhY0oJv4cHBdL4LvaSU2W8,12680
|
|
4
|
+
clockin/exceptions.py,sha256=3RrlcDwH-rnyD5moMrONkGZNjNT5zrHiPxs4ttT0ZYc,253
|
|
5
|
+
clockin/main.py,sha256=rzwqG4sRLp3pZlSzyk8hQNPPrMoKAKRBR5LMib_Gpjc,16891
|
|
6
|
+
clockin/utils.py,sha256=bj9A_3BwpOcarezOlMS5Q-vHTQTxePAOEmHPCFnH1qE,341
|
|
7
|
+
clockin_tracker-0.1.0.dist-info/WHEEL,sha256=CoDSoyhtC_eO_tlxRYzsTraPv1fPJRXFx91k6ISeAvA,81
|
|
8
|
+
clockin_tracker-0.1.0.dist-info/entry_points.txt,sha256=Nlh9EIsIVPScXTXW79Kfl6sHw3z492Hq7Q9IuSJlRHw,42
|
|
9
|
+
clockin_tracker-0.1.0.dist-info/METADATA,sha256=q4cTLOEZ41VyBhrtUIVk4TSp2CWRmxa33LDzwku2CTQ,8736
|
|
10
|
+
clockin_tracker-0.1.0.dist-info/RECORD,,
|