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 ADDED
@@ -0,0 +1,8 @@
1
+ AUTHOR = "markosnarinian"
2
+ APP_NAME = "clockin"
3
+
4
+
5
+ def main() -> None:
6
+ from clockin.main import app
7
+
8
+ app()
@@ -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
+ [![Upload Python Package](https://github.com/markosnarinian/clockin/actions/workflows/python-publish.yml/badge.svg)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.28
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ clockin = clockin:main
3
+