time-buddy 1.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Alen Zubic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: time-buddy
3
+ Version: 1.0.1
4
+ Summary: A simple to use time tracking CLI for macOS.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/alen-z/time-buddy
7
+ Project-URL: Repository, https://github.com/alen-z/time-buddy
8
+ Project-URL: Issues, https://github.com/alen-z/time-buddy/issues
9
+ Keywords: time-tracking,macos,cli,screen-time,productivity
10
+ Classifier: Operating System :: MacOS
11
+ Classifier: Environment :: Console
12
+ Classifier: Topic :: Utilities
13
+ Requires-Python: >=3.7
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: tzlocal
17
+ Requires-Dist: halo
18
+ Requires-Dist: colorama
19
+ Dynamic: license-file
20
+
21
+ # Time Buddy
22
+
23
+ A simple to use time tracking CLI for **macOS**.
24
+
25
+ Track your screen time based on macOS system logs to calculate how many hours you've worked each day.
26
+
27
+ ![Time Buddy Preview](assets/2025-12-time-buddy-preview.png)
28
+
29
+ ## Features
30
+
31
+ - 📊 Track screen time based on macOS unlock/lock events
32
+ - 📅 Calculate time spent per day with hourly breakdown visualization
33
+ - 💾 Smart caching for faster subsequent runs
34
+ - 🎨 Colorful terminal output with progress indicators
35
+
36
+ ## Requirements
37
+
38
+ - macOS (uses macOS system logs)
39
+ - Python 3.7+ (for development)
40
+
41
+ ## Setup (Required)
42
+
43
+ Before using Time Buddy, you must enable persistent logging for screen lock/unlock events:
44
+
45
+ ```bash
46
+ sudo log config --subsystem com.apple.loginwindow --mode "persist:info"
47
+ ```
48
+
49
+ This command configures macOS to persistently store login window events. Without this, the system may not retain the screen lock/unlock logs needed for accurate time tracking.
50
+
51
+ > **Note**: You only need to run this once. The setting persists across reboots.
52
+
53
+ ## Installation
54
+
55
+ ### pip (Recommended)
56
+
57
+ ```bash
58
+ pip install time-buddy
59
+ ```
60
+
61
+ ### Standalone Binary
62
+
63
+ If you don't have Python installed:
64
+
65
+ ```bash
66
+ curl -fsSL https://raw.githubusercontent.com/alen-z/time-buddy/main/install.sh | bash
67
+ ```
68
+
69
+ Or build from source:
70
+
71
+ ```bash
72
+ git clone https://github.com/alen-z/time-buddy.git
73
+ cd time-buddy
74
+ make install
75
+ make install-binary
76
+ ```
77
+
78
+ ## Usage
79
+
80
+ ```bash
81
+ # Show version
82
+ time-buddy --version
83
+
84
+ # Show screen time for the last 7 days (default)
85
+ time-buddy
86
+
87
+ # Show screen time for the last 30 days
88
+ time-buddy --days 30
89
+
90
+ # Show detailed session information
91
+ time-buddy --verbose
92
+
93
+ # Force refresh (ignore cache)
94
+ time-buddy --no-cache
95
+
96
+ # Include weekends in expected hours calculation
97
+ time-buddy --include-weekends
98
+
99
+ # Clear the cache
100
+ time-buddy --clear-cache
101
+ ```
102
+
103
+ ## Development
104
+
105
+ ```bash
106
+ # Install dependencies
107
+ make install
108
+
109
+ # Install locally for testing (editable mode)
110
+ pip install -e .
111
+
112
+ # Run the application
113
+ make run
114
+
115
+ # Create standalone binary
116
+ make create-binary
117
+
118
+ # Test the binary
119
+ make test-binary
120
+
121
+ # Clean build artifacts
122
+ make clean
123
+ ```
124
+
125
+ ## How It Works
126
+
127
+ Time Buddy reads macOS system logs for screen lock/unlock events and calculates:
128
+
129
+ - **Raw time**: Actual screen-on time between unlock and lock events
130
+ - **Block time**: Time span from first to last activity (your "work block")
131
+
132
+ The output shows a 24-hour visualization where each block represents an hour, colored by activity intensity.
133
+
134
+ ## License
135
+
136
+ MIT License
@@ -0,0 +1,116 @@
1
+ # Time Buddy
2
+
3
+ A simple to use time tracking CLI for **macOS**.
4
+
5
+ Track your screen time based on macOS system logs to calculate how many hours you've worked each day.
6
+
7
+ ![Time Buddy Preview](assets/2025-12-time-buddy-preview.png)
8
+
9
+ ## Features
10
+
11
+ - 📊 Track screen time based on macOS unlock/lock events
12
+ - 📅 Calculate time spent per day with hourly breakdown visualization
13
+ - 💾 Smart caching for faster subsequent runs
14
+ - 🎨 Colorful terminal output with progress indicators
15
+
16
+ ## Requirements
17
+
18
+ - macOS (uses macOS system logs)
19
+ - Python 3.7+ (for development)
20
+
21
+ ## Setup (Required)
22
+
23
+ Before using Time Buddy, you must enable persistent logging for screen lock/unlock events:
24
+
25
+ ```bash
26
+ sudo log config --subsystem com.apple.loginwindow --mode "persist:info"
27
+ ```
28
+
29
+ This command configures macOS to persistently store login window events. Without this, the system may not retain the screen lock/unlock logs needed for accurate time tracking.
30
+
31
+ > **Note**: You only need to run this once. The setting persists across reboots.
32
+
33
+ ## Installation
34
+
35
+ ### pip (Recommended)
36
+
37
+ ```bash
38
+ pip install time-buddy
39
+ ```
40
+
41
+ ### Standalone Binary
42
+
43
+ If you don't have Python installed:
44
+
45
+ ```bash
46
+ curl -fsSL https://raw.githubusercontent.com/alen-z/time-buddy/main/install.sh | bash
47
+ ```
48
+
49
+ Or build from source:
50
+
51
+ ```bash
52
+ git clone https://github.com/alen-z/time-buddy.git
53
+ cd time-buddy
54
+ make install
55
+ make install-binary
56
+ ```
57
+
58
+ ## Usage
59
+
60
+ ```bash
61
+ # Show version
62
+ time-buddy --version
63
+
64
+ # Show screen time for the last 7 days (default)
65
+ time-buddy
66
+
67
+ # Show screen time for the last 30 days
68
+ time-buddy --days 30
69
+
70
+ # Show detailed session information
71
+ time-buddy --verbose
72
+
73
+ # Force refresh (ignore cache)
74
+ time-buddy --no-cache
75
+
76
+ # Include weekends in expected hours calculation
77
+ time-buddy --include-weekends
78
+
79
+ # Clear the cache
80
+ time-buddy --clear-cache
81
+ ```
82
+
83
+ ## Development
84
+
85
+ ```bash
86
+ # Install dependencies
87
+ make install
88
+
89
+ # Install locally for testing (editable mode)
90
+ pip install -e .
91
+
92
+ # Run the application
93
+ make run
94
+
95
+ # Create standalone binary
96
+ make create-binary
97
+
98
+ # Test the binary
99
+ make test-binary
100
+
101
+ # Clean build artifacts
102
+ make clean
103
+ ```
104
+
105
+ ## How It Works
106
+
107
+ Time Buddy reads macOS system logs for screen lock/unlock events and calculates:
108
+
109
+ - **Raw time**: Actual screen-on time between unlock and lock events
110
+ - **Block time**: Time span from first to last activity (your "work block")
111
+
112
+ The output shows a 24-hour visualization where each block represents an hour, colored by activity intensity.
113
+
114
+ ## License
115
+
116
+ MIT License
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "time-buddy"
7
+ version = "1.0.1"
8
+ description = "A simple to use time tracking CLI for macOS."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.7"
12
+ classifiers = [
13
+ "Operating System :: MacOS",
14
+ "Environment :: Console",
15
+ "Topic :: Utilities",
16
+ ]
17
+ keywords = ["time-tracking", "macos", "cli", "screen-time", "productivity"]
18
+ dependencies = [
19
+ "tzlocal",
20
+ "halo",
21
+ "colorama",
22
+ ]
23
+
24
+ [project.scripts]
25
+ time-buddy = "time_buddy:main"
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/alen-z/time-buddy"
29
+ Repository = "https://github.com/alen-z/time-buddy"
30
+ Issues = "https://github.com/alen-z/time-buddy/issues"
31
+
32
+ [tool.setuptools]
33
+ py-modules = ["time_buddy"]
34
+
35
+ [tool.semantic_release]
36
+ version_toml = ["pyproject.toml:project.version"]
37
+ version_variables = ["time_buddy.py:__version__"]
38
+ commit_message = "chore(release): v{version}"
39
+
40
+ [tool.semantic_release.changelog]
41
+ changelog_file = "CHANGELOG.md"
42
+
43
+ [tool.semantic_release.remote]
44
+ type = "github"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: time-buddy
3
+ Version: 1.0.1
4
+ Summary: A simple to use time tracking CLI for macOS.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/alen-z/time-buddy
7
+ Project-URL: Repository, https://github.com/alen-z/time-buddy
8
+ Project-URL: Issues, https://github.com/alen-z/time-buddy/issues
9
+ Keywords: time-tracking,macos,cli,screen-time,productivity
10
+ Classifier: Operating System :: MacOS
11
+ Classifier: Environment :: Console
12
+ Classifier: Topic :: Utilities
13
+ Requires-Python: >=3.7
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: tzlocal
17
+ Requires-Dist: halo
18
+ Requires-Dist: colorama
19
+ Dynamic: license-file
20
+
21
+ # Time Buddy
22
+
23
+ A simple to use time tracking CLI for **macOS**.
24
+
25
+ Track your screen time based on macOS system logs to calculate how many hours you've worked each day.
26
+
27
+ ![Time Buddy Preview](assets/2025-12-time-buddy-preview.png)
28
+
29
+ ## Features
30
+
31
+ - 📊 Track screen time based on macOS unlock/lock events
32
+ - 📅 Calculate time spent per day with hourly breakdown visualization
33
+ - 💾 Smart caching for faster subsequent runs
34
+ - 🎨 Colorful terminal output with progress indicators
35
+
36
+ ## Requirements
37
+
38
+ - macOS (uses macOS system logs)
39
+ - Python 3.7+ (for development)
40
+
41
+ ## Setup (Required)
42
+
43
+ Before using Time Buddy, you must enable persistent logging for screen lock/unlock events:
44
+
45
+ ```bash
46
+ sudo log config --subsystem com.apple.loginwindow --mode "persist:info"
47
+ ```
48
+
49
+ This command configures macOS to persistently store login window events. Without this, the system may not retain the screen lock/unlock logs needed for accurate time tracking.
50
+
51
+ > **Note**: You only need to run this once. The setting persists across reboots.
52
+
53
+ ## Installation
54
+
55
+ ### pip (Recommended)
56
+
57
+ ```bash
58
+ pip install time-buddy
59
+ ```
60
+
61
+ ### Standalone Binary
62
+
63
+ If you don't have Python installed:
64
+
65
+ ```bash
66
+ curl -fsSL https://raw.githubusercontent.com/alen-z/time-buddy/main/install.sh | bash
67
+ ```
68
+
69
+ Or build from source:
70
+
71
+ ```bash
72
+ git clone https://github.com/alen-z/time-buddy.git
73
+ cd time-buddy
74
+ make install
75
+ make install-binary
76
+ ```
77
+
78
+ ## Usage
79
+
80
+ ```bash
81
+ # Show version
82
+ time-buddy --version
83
+
84
+ # Show screen time for the last 7 days (default)
85
+ time-buddy
86
+
87
+ # Show screen time for the last 30 days
88
+ time-buddy --days 30
89
+
90
+ # Show detailed session information
91
+ time-buddy --verbose
92
+
93
+ # Force refresh (ignore cache)
94
+ time-buddy --no-cache
95
+
96
+ # Include weekends in expected hours calculation
97
+ time-buddy --include-weekends
98
+
99
+ # Clear the cache
100
+ time-buddy --clear-cache
101
+ ```
102
+
103
+ ## Development
104
+
105
+ ```bash
106
+ # Install dependencies
107
+ make install
108
+
109
+ # Install locally for testing (editable mode)
110
+ pip install -e .
111
+
112
+ # Run the application
113
+ make run
114
+
115
+ # Create standalone binary
116
+ make create-binary
117
+
118
+ # Test the binary
119
+ make test-binary
120
+
121
+ # Clean build artifacts
122
+ make clean
123
+ ```
124
+
125
+ ## How It Works
126
+
127
+ Time Buddy reads macOS system logs for screen lock/unlock events and calculates:
128
+
129
+ - **Raw time**: Actual screen-on time between unlock and lock events
130
+ - **Block time**: Time span from first to last activity (your "work block")
131
+
132
+ The output shows a 24-hour visualization where each block represents an hour, colored by activity intensity.
133
+
134
+ ## License
135
+
136
+ MIT License
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ time_buddy.py
5
+ time_buddy.egg-info/PKG-INFO
6
+ time_buddy.egg-info/SOURCES.txt
7
+ time_buddy.egg-info/dependency_links.txt
8
+ time_buddy.egg-info/entry_points.txt
9
+ time_buddy.egg-info/requires.txt
10
+ time_buddy.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ time-buddy = time_buddy:main
@@ -0,0 +1,3 @@
1
+ tzlocal
2
+ halo
3
+ colorama
@@ -0,0 +1 @@
1
+ time_buddy
@@ -0,0 +1,502 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import json
4
+ import os
5
+ import sqlite3
6
+ import subprocess
7
+ from collections import defaultdict
8
+ from datetime import date, datetime, time, timedelta
9
+
10
+ __version__ = "1.0.1"
11
+
12
+ def get_version():
13
+ """Returns the package version."""
14
+ try:
15
+ from importlib.metadata import version
16
+ return version("time-buddy")
17
+ except Exception:
18
+ return __version__
19
+
20
+ # --- Configuration ---
21
+ EXPECTED_HOURS_PER_DAY = 7.5
22
+
23
+ def get_db_path():
24
+ """Returns the platform-specific path to the database file."""
25
+ app_name = "TimeBuddy"
26
+ # For macOS, use the Application Support directory
27
+ home = os.path.expanduser("~")
28
+ app_support_dir = os.path.join(home, "Library", "Application Support", app_name)
29
+
30
+ if not os.path.exists(app_support_dir):
31
+ os.makedirs(app_support_dir)
32
+
33
+ return os.path.join(app_support_dir, 'time_buddy.db')
34
+
35
+ DB_FILE = get_db_path()
36
+
37
+
38
+ # --- Database Functions ---
39
+ def db_connect():
40
+ """Connects to the SQLite database."""
41
+ return sqlite3.connect(DB_FILE)
42
+
43
+ def db_init(conn):
44
+ """Initializes the database schema."""
45
+ with conn:
46
+ conn.execute("""
47
+ CREATE TABLE IF NOT EXISTS raw_logs (
48
+ day TEXT,
49
+ timestamp TEXT,
50
+ data TEXT,
51
+ UNIQUE(day, timestamp, data)
52
+ )
53
+ """)
54
+ conn.execute("""
55
+ CREATE TABLE IF NOT EXISTS fetched_days (
56
+ day TEXT PRIMARY KEY
57
+ )
58
+ """)
59
+
60
+ def db_is_day_cached(conn, day):
61
+ """Checks if a past day has been fully cached."""
62
+ cursor = conn.cursor()
63
+ cursor.execute("SELECT 1 FROM fetched_days WHERE day = ?", (day.isoformat(),))
64
+ return cursor.fetchone() is not None
65
+
66
+ def db_get_logs_for_day(conn, day):
67
+ """Retrieves all log entries for a given day from the cache."""
68
+ cursor = conn.cursor()
69
+ cursor.execute("SELECT data FROM raw_logs WHERE day = ?", (day.isoformat(),))
70
+ return [json.loads(row[0]) for row in cursor.fetchall()]
71
+
72
+ def db_cache_logs(conn, day, logs):
73
+ """Caches a list of log entries for a given day."""
74
+ log_data = [(day.isoformat(), entry.get("timestamp"), json.dumps(entry)) for entry in logs]
75
+ with conn:
76
+ conn.executemany("INSERT OR IGNORE INTO raw_logs (day, timestamp, data) VALUES (?, ?, ?)", log_data)
77
+
78
+ def db_mark_day_as_cached(conn, day):
79
+ """Marks a day as fully fetched in the database."""
80
+ with conn:
81
+ conn.execute("INSERT OR IGNORE INTO fetched_days (day) VALUES (?)", (day.isoformat(),))
82
+
83
+
84
+ def print_hourly_breakdown(day: date, hourly_durations: defaultdict, block_duration: timedelta):
85
+ """Prints a single line of 24 colored blocks representing a day's screen time."""
86
+ # --- Color gradient (10 steps from red to green in ANSI 256-color) ---
87
+ gradient_colors = [196, 202, 208, 214, 220, 226, 190, 154, 118, 46]
88
+
89
+ total_duration = sum(hourly_durations.values(), timedelta())
90
+ total_hours = total_duration.total_seconds() / 3600
91
+ total_block_hours = block_duration.total_seconds() / 3600
92
+ raw_percentage = (total_hours / EXPECTED_HOURS_PER_DAY) * 100
93
+ block_percentage = (total_block_hours / EXPECTED_HOURS_PER_DAY) * 100
94
+
95
+ # Determine day of week and apply appropriate color
96
+ day_of_week = day.weekday() # Monday=0, Sunday=6
97
+ day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
98
+ day_name = day_names[day_of_week]
99
+
100
+ # Use cyan for weekends, default color for weekdays
101
+ if day_of_week >= 5: # Saturday or Sunday
102
+ day_label = f" \033[38;5;51m({day_name})\033[0m"
103
+ else:
104
+ day_label = f" ({day_name})"
105
+
106
+ output_line = f"{day.isoformat()}{day_label}: "
107
+
108
+ for hour in range(24):
109
+ minutes = hourly_durations.get(hour, timedelta()).total_seconds() / 60
110
+
111
+ color_code = ""
112
+ if minutes > 0:
113
+ # Map minutes (1-60) to a gradient index (0-9)
114
+ gradient_index = min(int((minutes - 1) / 6), len(gradient_colors) - 1)
115
+ ansi_color = gradient_colors[gradient_index]
116
+ color_code = f'\033[38;5;{ansi_color}m'
117
+ else:
118
+ # Use a faint grey for hours with no activity
119
+ color_code = '\033[38;5;240m'
120
+
121
+ output_line += f"{color_code}â–ˆ\033[0m"
122
+
123
+ raw_str = f"Raw: {total_hours:.1f} h ({raw_percentage:.0f}%)"
124
+ block_str = f"Block: {total_block_hours:.1f} h ({block_percentage:.0f}%)"
125
+ output_line += f" {raw_str:<22}{block_str}"
126
+ print(output_line)
127
+
128
+
129
+ def process_day_logs(logs, current_day, verbose=False):
130
+ """Processes log entries for a single day and returns hourly durations and block duration."""
131
+ events = []
132
+ for entry in logs:
133
+ timestamp_str = entry.get("timestamp")
134
+ if not timestamp_str:
135
+ continue
136
+
137
+ try:
138
+ timestamp = datetime.fromisoformat(timestamp_str)
139
+ except ValueError:
140
+ try:
141
+ timestamp = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S.%f")
142
+ except ValueError:
143
+ continue
144
+
145
+ # Ensure the event belongs to the current day being processed
146
+ if timestamp.date() != current_day:
147
+ continue
148
+
149
+ message = entry.get("eventMessage", "")
150
+
151
+ if "screenIsUnlocked" in message:
152
+ events.append({'timestamp': timestamp, 'type': 'unlocked'})
153
+ elif "screenIsLocked" in message:
154
+ events.append({'timestamp': timestamp, 'type': 'locked'})
155
+
156
+ events.sort(key=lambda x: x['timestamp'])
157
+
158
+ # --- Calculate precise screen time (sum of unlock-to-lock sessions) ---
159
+ hourly_durations = defaultdict(timedelta)
160
+ unlock_time = None
161
+ if verbose:
162
+ print(f"Processing sessions for {current_day.isoformat()}:")
163
+
164
+ for event in events:
165
+ if event['type'] == 'unlocked':
166
+ if unlock_time is None:
167
+ unlock_time = event['timestamp']
168
+ elif event['type'] == 'locked':
169
+ if unlock_time is not None:
170
+ lock_time = event['timestamp']
171
+ duration = lock_time - unlock_time
172
+ if verbose:
173
+ print(f" - Session from {unlock_time.strftime('%Y-%m-%d %H:%M:%S')} to {lock_time.strftime('%Y-%m-%d %H:%M:%S')} (Duration: {duration})")
174
+
175
+ current_time = unlock_time
176
+ while current_time < lock_time:
177
+ current_hour_start = current_time.replace(minute=0, second=0, microsecond=0)
178
+ next_hour_start = current_hour_start + timedelta(hours=1)
179
+
180
+ segment_end = min(lock_time, next_hour_start)
181
+ duration_in_hour = segment_end - current_time
182
+
183
+ hourly_durations[current_time.hour] += duration_in_hour
184
+
185
+ current_time = next_hour_start
186
+
187
+ unlock_time = None
188
+
189
+ # --- Calculate Block Time (total span of continuous activity) ---
190
+ total_block_duration = timedelta()
191
+ active_hours = sorted([h for h, d in hourly_durations.items() if d.total_seconds() > 0])
192
+
193
+ if active_hours:
194
+ current_block_start_hour = active_hours[0]
195
+ for i in range(1, len(active_hours)):
196
+ # If there's a gap of more than an hour, the block is broken
197
+ if active_hours[i] > active_hours[i-1] + 1:
198
+ # Process the completed block
199
+ block_end_hour = active_hours[i-1]
200
+
201
+ first_event_in_block = min([e['timestamp'] for e in events if e['timestamp'].hour == current_block_start_hour])
202
+ last_event_in_block = max([e['timestamp'] for e in events if e['timestamp'].hour == block_end_hour])
203
+
204
+ total_block_duration += last_event_in_block - first_event_in_block
205
+
206
+ # Start a new block
207
+ current_block_start_hour = active_hours[i]
208
+
209
+ # Process the final block
210
+ last_block_end_hour = active_hours[-1]
211
+ first_event_in_block = min([e['timestamp'] for e in events if e['timestamp'].hour == current_block_start_hour])
212
+ last_event_in_block = max([e['timestamp'] for e in events if e['timestamp'].hour == last_block_end_hour])
213
+ total_block_duration += last_event_in_block - first_event_in_block
214
+
215
+ return hourly_durations, total_block_duration, unlock_time
216
+
217
+
218
+ def get_screen_time(days_back, verbose=False, no_cache=False, include_weekends=False):
219
+ """
220
+ Calculates screen time for the last N days, fetching logs day by day.
221
+
222
+ Args:
223
+ days_back: Number of days to look back
224
+ verbose: Print detailed session information
225
+ no_cache: Force refetching of all logs
226
+ include_weekends: Count weekends toward expected work hours (default: False)
227
+ """
228
+ # Lazy imports for faster --version response (third-party libs are slow to import)
229
+ from tzlocal import get_localzone
230
+ from halo import Halo
231
+ import colorama
232
+
233
+ conn = db_connect()
234
+ db_init(conn)
235
+
236
+ daily_hourly_durations = {}
237
+ daily_block_durations = {}
238
+ today = datetime.now().date()
239
+ total_actual_hours = 0
240
+ days_with_activity = set()
241
+ local_tz = get_localzone()
242
+
243
+ spinner = None
244
+ if not verbose:
245
+ spinner = Halo(text='Initializing...', spinner='dots')
246
+ spinner.start()
247
+
248
+ try:
249
+ # We must process chronologically to handle sessions crossing midnight
250
+ dates_to_process = [today - timedelta(days=i) for i in range(days_back - 1, -1, -1)]
251
+
252
+ # Track whether the previous processed day ended unlocked (carry-over)
253
+ carry_over_unlocked = False
254
+ carry_over_tzinfo = None
255
+
256
+ for current_day in dates_to_process:
257
+ if spinner:
258
+ spinner.text = f"Processing {current_day.isoformat()}..."
259
+
260
+ logs = []
261
+ is_cached = not no_cache and db_is_day_cached(conn, current_day)
262
+
263
+ # Past days can be loaded from cache. Today is always fetched fresh.
264
+ if is_cached and current_day != today:
265
+ if spinner:
266
+ spinner.text = f"Loading logs from cache for {current_day.isoformat()}..."
267
+ logs = db_get_logs_for_day(conn, current_day)
268
+ if verbose:
269
+ print(f"\nLoaded {len(logs)} log entries from cache for {current_day.isoformat()}.")
270
+ else:
271
+ if spinner:
272
+ spinner.text = f"Fetching logs for {current_day.isoformat()}..."
273
+
274
+ start_of_day = datetime.combine(current_day, datetime.min.time())
275
+ end_of_day = datetime.combine(current_day, datetime.max.time())
276
+ start_of_day_aware = start_of_day.replace(tzinfo=local_tz)
277
+ end_of_day_aware = end_of_day.replace(tzinfo=local_tz)
278
+
279
+ if verbose:
280
+ print(f"\nFetching logs for {current_day.isoformat()}...")
281
+
282
+ predicate = 'process == "loginwindow" and eventMessage contains "com.apple.sessionagent.screenIs"'
283
+ command = [
284
+ 'log', 'show', '--style', 'json',
285
+ '--predicate', predicate,
286
+ '--start', start_of_day_aware.strftime('%Y-%m-%d %H:%M:%S%z'),
287
+ '--end', end_of_day_aware.strftime('%Y-%m-%d %H:%M:%S%z')
288
+ ]
289
+
290
+ try:
291
+ result = subprocess.run(command, capture_output=True, text=True, check=True)
292
+ fetched_logs = json.loads(result.stdout)
293
+ logs.extend(fetched_logs)
294
+
295
+ if verbose:
296
+ print(f"Found {len(logs)} log entries.")
297
+
298
+ # Cache the newly fetched logs
299
+ db_cache_logs(conn, current_day, logs)
300
+ if current_day != today:
301
+ db_mark_day_as_cached(conn, current_day)
302
+
303
+ except subprocess.CalledProcessError as e:
304
+ if e.returncode == 1 and not e.stdout and not e.stderr:
305
+ pass # No logs found
306
+ else:
307
+ if spinner:
308
+ spinner.fail(f"Error executing log command for {current_day.isoformat()}")
309
+ print(f"Error executing log command for {current_day.isoformat()}: {e}")
310
+ except json.JSONDecodeError:
311
+ if spinner:
312
+ spinner.fail(f"Error decoding JSON from log output for {current_day.isoformat()}")
313
+ print(f"Error decoding JSON from log output for {current_day.isoformat()}.")
314
+
315
+ if not logs:
316
+ carry_over_unlocked = False
317
+ continue
318
+
319
+ # Inject a synthetic unlocked event at 00:00 only if previous day carried over unlocked
320
+ if carry_over_unlocked:
321
+ start_of_day = datetime.combine(current_day, datetime.min.time())
322
+ tzinfo = carry_over_tzinfo or local_tz
323
+ start_of_day = start_of_day.replace(tzinfo=tzinfo)
324
+ logs.append({
325
+ "timestamp": start_of_day.isoformat(),
326
+ "eventMessage": "screenIsUnlocked (synthetic carryover)"
327
+ })
328
+
329
+ hourly_durations, block_duration, last_unlock_time = process_day_logs(logs, current_day, verbose)
330
+
331
+ # If the last event of the day was an unlock, it's an open session
332
+ if last_unlock_time is not None:
333
+ # If it's today, calculate up to now
334
+ if current_day == today:
335
+ now = datetime.now(local_tz)
336
+ if last_unlock_time.date() == today:
337
+ duration = now - last_unlock_time
338
+ if verbose:
339
+ print(f" - Active session: from {last_unlock_time.strftime('%H:%M:%S')} to now (Duration: {duration})")
340
+
341
+ current_time = last_unlock_time
342
+ while current_time < now:
343
+ current_hour_start = current_time.replace(minute=0, second=0, microsecond=0)
344
+ next_hour_start = current_hour_start + timedelta(hours=1)
345
+
346
+ segment_end = min(now, next_hour_start)
347
+ duration_in_hour = segment_end - current_time
348
+
349
+ hourly_durations[current_time.hour] += duration_in_hour
350
+
351
+ current_time = next_hour_start
352
+
353
+ block_duration += now - last_unlock_time
354
+ # If it's a past day, calculate up to midnight
355
+ else:
356
+ end_of_day = datetime.combine(current_day, time(23, 59, 59, 999999), tzinfo=last_unlock_time.tzinfo)
357
+ duration = end_of_day - last_unlock_time
358
+ if verbose:
359
+ print(f" - Session carried over to next day: from {last_unlock_time.strftime('%H:%M:%S')} to 23:59:59 (Duration: {duration})")
360
+
361
+ current_time = last_unlock_time
362
+ while current_time < end_of_day:
363
+ current_hour_start = current_time.replace(minute=0, second=0, microsecond=0)
364
+ next_hour_start = current_hour_start + timedelta(hours=1)
365
+
366
+ segment_end = min(end_of_day, next_hour_start)
367
+ duration_in_hour = segment_end - current_time
368
+
369
+ hourly_durations[current_time.hour] += duration_in_hour
370
+
371
+ current_time = next_hour_start
372
+
373
+ block_duration += end_of_day - last_unlock_time
374
+
375
+ # Update carry-over state for the next day
376
+ if last_unlock_time is not None:
377
+ carry_over_unlocked = True
378
+ carry_over_tzinfo = last_unlock_time.tzinfo
379
+ else:
380
+ carry_over_unlocked = False
381
+ carry_over_tzinfo = None
382
+
383
+ if any(duration.total_seconds() > 0 for duration in hourly_durations.values()):
384
+ daily_hourly_durations[current_day] = hourly_durations
385
+ daily_block_durations[current_day] = block_duration
386
+ days_with_activity.add(current_day)
387
+ if verbose:
388
+ total_day_hours = sum(hourly_durations.values(), timedelta()).total_seconds() / 3600
389
+ print(f"Calculated {total_day_hours:.1f} hours of screen time.")
390
+
391
+ except KeyboardInterrupt:
392
+ if spinner:
393
+ spinner.warn("Process interrupted by user.")
394
+ print("\n\nProcess interrupted by user. Displaying summary for data collected so far...")
395
+
396
+ finally:
397
+ conn.close()
398
+
399
+ if spinner:
400
+ spinner.succeed("Log processing complete.")
401
+ spinner.stop()
402
+ colorama.reinit()
403
+
404
+ # --- Print Summaries ---
405
+ print("\n--- Daily Screen Time Summary ---")
406
+ if not daily_hourly_durations:
407
+ print("No screen time data found for the selected period.")
408
+ return
409
+
410
+ sorted_days = sorted(daily_hourly_durations.keys())
411
+ for day in sorted_days:
412
+ print_hourly_breakdown(day, daily_hourly_durations[day], daily_block_durations[day])
413
+
414
+ # --- Monthly Summary ---
415
+ print("\n--- Monthly Summary ---")
416
+ for day_data in daily_hourly_durations.values():
417
+ total_actual_hours += sum(day_data.values(), timedelta()).total_seconds() / 3600
418
+
419
+ total_block_hours = sum([d.total_seconds() for d in daily_block_durations.values()]) / 3600
420
+
421
+ # Calculate expected hours based on include_weekends flag
422
+ if include_weekends:
423
+ # All active days count toward expected hours
424
+ total_expected_hours = len(days_with_activity) * EXPECTED_HOURS_PER_DAY
425
+ else:
426
+ # Only count weekdays (Mon-Fri) towards expected hours
427
+ weekday_count = sum(1 for day in days_with_activity if day.weekday() < 5)
428
+ total_expected_hours = weekday_count * EXPECTED_HOURS_PER_DAY
429
+
430
+ if total_expected_hours > 0:
431
+ monthly_raw_percentage = (total_actual_hours / total_expected_hours) * 100
432
+ monthly_block_percentage = (total_block_hours / total_expected_hours) * 100
433
+
434
+ raw_str = f"Raw: {total_actual_hours:.1f} h ({monthly_raw_percentage:.0f}%)"
435
+ block_str = f"Block: {total_block_hours:.1f} h ({monthly_block_percentage:.0f}%)"
436
+
437
+ total_days = len(days_with_activity)
438
+ if not include_weekends:
439
+ weekday_count = sum(1 for day in days_with_activity if day.weekday() < 5)
440
+ weekend_count = total_days - weekday_count
441
+ day_breakdown = f"{total_days} active day(s)"
442
+ if weekend_count > 0:
443
+ day_breakdown += f" ({weekday_count} weekdays, {weekend_count} weekend days)"
444
+ else:
445
+ day_breakdown = f"{total_days} active day(s)"
446
+
447
+ print(f"Total for {day_breakdown}: {raw_str:<22}{block_str}")
448
+ else:
449
+ print("No activity to summarize.")
450
+
451
+
452
+ def main():
453
+ """
454
+ Main function to run the CLI.
455
+ """
456
+ parser = argparse.ArgumentParser(description="A simple to use time tracking CLI for macOS.")
457
+ parser.add_argument(
458
+ '-V', '--version',
459
+ action='version',
460
+ version=f'time-buddy {get_version()}'
461
+ )
462
+ parser.add_argument(
463
+ '--days',
464
+ type=int,
465
+ default=7,
466
+ help='Number of days back to calculate screen time for. (default: 7)'
467
+ )
468
+ parser.add_argument(
469
+ '-v', '--verbose',
470
+ action='store_true',
471
+ help='Print detailed session information for validation.'
472
+ )
473
+ parser.add_argument(
474
+ '--no-cache',
475
+ action='store_true',
476
+ help='Force refetching of all logs, ignoring the cache.'
477
+ )
478
+ parser.add_argument(
479
+ '--clear-cache',
480
+ action='store_true',
481
+ help='Delete the cache file and exit.'
482
+ )
483
+ parser.add_argument(
484
+ '--include-weekends',
485
+ action='store_true',
486
+ help='Include weekends in expected work hours calculation. (default: False)'
487
+ )
488
+ args = parser.parse_args()
489
+
490
+ if args.clear_cache:
491
+ if os.path.exists(DB_FILE):
492
+ os.remove(DB_FILE)
493
+ print(f"Cache file '{DB_FILE}' has been deleted.")
494
+ else:
495
+ print("No cache file to delete.")
496
+ return
497
+
498
+ get_screen_time(args.days, args.verbose, args.no_cache, args.include_weekends)
499
+
500
+
501
+ if __name__ == "__main__":
502
+ main()