chrome-time-clock 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.
@@ -0,0 +1,17 @@
1
+ """
2
+ Chrome Time Clock
3
+
4
+ Infer approximate workday start/end from Chrome browser history and plot/merge work hours.
5
+ """
6
+
7
+ from chrome_time_clock.extractor import extract_workhours
8
+ from chrome_time_clock.merger import merge_blocks
9
+ from chrome_time_clock.plotter import plot_workhours
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = [
14
+ "extract_workhours",
15
+ "plot_workhours",
16
+ "merge_blocks",
17
+ ]
@@ -0,0 +1,228 @@
1
+ """Unified command-line interface for chrome-time-clock."""
2
+
3
+ import argparse
4
+ import sys
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from chrome_time_clock.extractor import DEFAULT_GAP_MINUTES, DEFAULT_OUT_DIR, DEFAULT_PROFILE, DEFAULT_TIMEZONE, extract_workhours, write_csv, write_markdown
9
+ from chrome_time_clock.merger import merge_blocks
10
+ from chrome_time_clock.plotter import plot_workhours
11
+
12
+
13
+ def main() -> None:
14
+ """Main entry point for the unified chrome-time-clock CLI."""
15
+ parser = argparse.ArgumentParser(
16
+ prog="chrome-time-clock",
17
+ description="Chrome Time Clock: Infer, plot, and merge work hours from browser history.",
18
+ )
19
+
20
+ subparsers = parser.add_subparsers(dest="command", required=True, title="subcommands")
21
+
22
+ # ── Extract Subcommand ────────────────────────────────────────────────────
23
+ extract_parser = subparsers.add_parser(
24
+ "extract",
25
+ help="Infer workday start/end from browser history",
26
+ description="Infer workday start/end from Chrome/Chromium/Brave browser history.",
27
+ )
28
+ extract_parser.add_argument(
29
+ "--profile",
30
+ default=DEFAULT_PROFILE,
31
+ help=f"Browser profile name (default: {DEFAULT_PROFILE})",
32
+ )
33
+ extract_parser.add_argument(
34
+ "--browser",
35
+ default="chrome",
36
+ choices=["chrome", "chromium", "brave"],
37
+ help="Browser type (default: chrome)",
38
+ )
39
+ extract_parser.add_argument(
40
+ "--history-db",
41
+ default=None,
42
+ help="Direct path to History SQLite file (overrides browser/profile resolution)",
43
+ )
44
+ extract_parser.add_argument(
45
+ "--from",
46
+ dest="date_from",
47
+ default=None,
48
+ help="Start date YYYY-MM-DD (inclusive)",
49
+ )
50
+ extract_parser.add_argument(
51
+ "--to",
52
+ dest="date_to",
53
+ default=None,
54
+ help="End date YYYY-MM-DD (inclusive)",
55
+ )
56
+ extract_parser.add_argument(
57
+ "--timezone",
58
+ default=DEFAULT_TIMEZONE,
59
+ help=f"IANA timezone (default: {DEFAULT_TIMEZONE})",
60
+ )
61
+ extract_parser.add_argument(
62
+ "--gap-minutes",
63
+ type=int,
64
+ default=DEFAULT_GAP_MINUTES,
65
+ help=f"Inactivity gap threshold in minutes (default: {DEFAULT_GAP_MINUTES})",
66
+ )
67
+ extract_parser.add_argument(
68
+ "--out",
69
+ default=DEFAULT_OUT_DIR,
70
+ help=f"Output directory (default: {DEFAULT_OUT_DIR})",
71
+ )
72
+ extract_parser.add_argument(
73
+ "--markdown",
74
+ action="store_true",
75
+ help="Also write daily_summary.md inside the output directory",
76
+ )
77
+
78
+ # ── Plot Subcommand ───────────────────────────────────────────────────────
79
+ plot_parser = subparsers.add_parser(
80
+ "plot",
81
+ help="Work-hour timelines and histograms",
82
+ description="Generate work-hour timelines and histograms in dark Soilytix style.",
83
+ )
84
+ plot_parser.add_argument(
85
+ "csv",
86
+ help="Path to the daily summary CSV file",
87
+ )
88
+ plot_parser.add_argument(
89
+ "--from",
90
+ dest="date_from",
91
+ default="2026-02-22",
92
+ help="Start date YYYY-MM-DD (inclusive) (default: 2026-02-22)",
93
+ )
94
+ plot_parser.add_argument(
95
+ "--to",
96
+ dest="date_to",
97
+ default=None,
98
+ help="End date YYYY-MM-DD (inclusive)",
99
+ )
100
+ plot_parser.add_argument(
101
+ "--bins",
102
+ type=int,
103
+ default=15,
104
+ help="Number of bins for histograms (default: 15)",
105
+ )
106
+ plot_parser.add_argument(
107
+ "--out",
108
+ default="workhours_plot",
109
+ help="Output file stem — .png and .pdf are appended (default: workhours_plot)",
110
+ )
111
+ plot_parser.add_argument(
112
+ "--show",
113
+ action="store_true",
114
+ help="Display the plot window",
115
+ )
116
+
117
+ # ── Merge Subcommand ──────────────────────────────────────────────────────
118
+ merge_parser = subparsers.add_parser(
119
+ "merge",
120
+ help="Merge blocks.csv files",
121
+ description="Merge multiple blocks.csv files, collapsing overlapping time intervals.",
122
+ )
123
+ merge_parser.add_argument(
124
+ "files",
125
+ nargs="+",
126
+ help="List of blocks.csv files to merge",
127
+ )
128
+ merge_parser.add_argument(
129
+ "--out-blocks",
130
+ default="merged_blocks.csv",
131
+ help="Output filename for merged blocks (default: merged_blocks.csv)",
132
+ )
133
+ merge_parser.add_argument(
134
+ "--out-daily",
135
+ default="merged_daily.csv",
136
+ help="Output filename for derived daily summary (default: merged_daily.csv)",
137
+ )
138
+
139
+ # Parse arguments
140
+ args = parser.parse_args()
141
+
142
+ try:
143
+ if args.command == "extract":
144
+ date_from = (
145
+ datetime.strptime(args.date_from, "%Y-%m-%d").date() if args.date_from else None
146
+ )
147
+ date_to = (
148
+ datetime.strptime(args.date_to, "%Y-%m-%d").date() if args.date_to else None
149
+ )
150
+
151
+ daily_rows, block_rows, warnings = extract_workhours(
152
+ browser=args.browser,
153
+ profile=args.profile,
154
+ history_db=args.history_db,
155
+ date_from=date_from,
156
+ date_to=date_to,
157
+ timezone_str=args.timezone,
158
+ gap_minutes=args.gap_minutes,
159
+ )
160
+
161
+ if not daily_rows:
162
+ print("No visits found after filtering. Nothing to do.")
163
+ return
164
+
165
+ print(f"Days summarised: {len(daily_rows)}")
166
+ for w in warnings:
167
+ print(f" {w}")
168
+
169
+ out_dir = Path(args.out)
170
+ out_dir.mkdir(parents=True, exist_ok=True)
171
+
172
+ daily_path = out_dir / "daily_summary.csv"
173
+ blocks_path = out_dir / "blocks.csv"
174
+
175
+ write_csv(
176
+ daily_rows,
177
+ daily_path,
178
+ ["date", "first_seen", "last_seen", "gross_span_hours", "active_block_hours", "n_blocks", "n_visits"],
179
+ )
180
+ write_csv(
181
+ block_rows,
182
+ blocks_path,
183
+ ["date", "block_start", "block_end", "duration_hours", "n_visits"],
184
+ )
185
+
186
+ if args.markdown:
187
+ md_path = out_dir / "daily_summary.md"
188
+ write_markdown(daily_rows, md_path)
189
+ print(f"Markdown written to: {md_path}")
190
+
191
+ print(f"Output written to: {out_dir.resolve()}")
192
+ print(f" {daily_path.name}: {len(daily_rows)} days")
193
+ print(f" {blocks_path.name}: {len(block_rows)} blocks")
194
+
195
+ elif args.command == "plot":
196
+ date_from = (
197
+ datetime.strptime(args.date_from, "%Y-%m-%d").date() if args.date_from else None
198
+ )
199
+ date_to = (
200
+ datetime.strptime(args.date_to, "%Y-%m-%d").date() if args.date_to else None
201
+ )
202
+
203
+ plot_workhours(
204
+ csv_path=args.csv,
205
+ date_from=date_from,
206
+ date_to=date_to,
207
+ bins=args.bins,
208
+ out_stem=args.out,
209
+ show=args.show,
210
+ )
211
+
212
+ elif args.command == "merge":
213
+ merge_blocks(
214
+ files=args.files,
215
+ out_blocks=args.out_blocks,
216
+ out_daily=args.out_daily,
217
+ )
218
+ print(f"Successfully merged {len(args.files)} file(s).")
219
+ print(f"Written: {args.out_blocks}")
220
+ print(f"Written: {args.out_daily}")
221
+
222
+ except Exception as e:
223
+ print(f"Error: {e}", file=sys.stderr)
224
+ sys.exit(1)
225
+
226
+
227
+ if __name__ == "__main__":
228
+ main()
@@ -0,0 +1,379 @@
1
+ """Infer approximate workday start/end from Chrome browser history."""
2
+
3
+ import argparse
4
+ import csv
5
+ import os
6
+ import shutil
7
+ import sqlite3
8
+ import sys
9
+ import tempfile
10
+ from datetime import date, datetime, timedelta, timezone
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional, Tuple, Union
13
+ from zoneinfo import ZoneInfo
14
+
15
+ # Chrome/WebKit epoch: microseconds since 1601-01-01 00:00 UTC
16
+ CHROME_EPOCH = datetime(1601, 1, 1, tzinfo=timezone.utc)
17
+
18
+ INTERNAL_SCHEMES = ("chrome://", "chrome-extension://", "about:", "file://")
19
+
20
+ DEFAULT_TIMEZONE = "Europe/Amsterdam"
21
+ DEFAULT_GAP_MINUTES = 60
22
+ DEFAULT_PROFILE = "Default"
23
+ DEFAULT_OUT_DIR = "./chrome_workhours_export"
24
+
25
+ WARN_MIN_VISITS_PER_DAY = 5
26
+ WARN_MAX_SPAN_HOURS = 16
27
+ WARN_EARLIEST_HOUR = 5
28
+ WARN_LATEST_HOUR_MINUTE = (23, 30)
29
+
30
+
31
+ def resolve_history_path(browser: str, profile: str, history_db: Optional[Union[str, Path]]) -> Path:
32
+ """Resolve the path to the browser history database based on OS and browser."""
33
+ if history_db:
34
+ return Path(history_db).expanduser()
35
+
36
+ home = Path.home()
37
+ browser_lower = browser.lower()
38
+
39
+ if sys.platform == "darwin": # macOS
40
+ roots = {
41
+ "chrome": home / "Library/Application Support/Google/Chrome",
42
+ "chromium": home / "Library/Application Support/Chromium",
43
+ "brave": home / "Library/Application Support/BraveSoftware/Brave-Browser",
44
+ }
45
+ elif sys.platform == "win32": # Windows
46
+ local_app_data = Path(os.environ.get("LOCALAPPDATA", home / "AppData/Local"))
47
+ roots = {
48
+ "chrome": local_app_data / "Google/Chrome/User Data",
49
+ "chromium": local_app_data / "Chromium/User Data",
50
+ "brave": local_app_data / "BraveSoftware/Brave-Browser/User Data",
51
+ }
52
+ else: # Linux / other Unix
53
+ roots = {
54
+ "chrome": home / ".config/google-chrome",
55
+ "chromium": home / ".config/chromium",
56
+ "brave": home / ".config/BraveSoftware/Brave-Browser",
57
+ }
58
+
59
+ root = roots.get(browser_lower)
60
+ if root is None:
61
+ raise ValueError(
62
+ f"Unknown browser '{browser}' on platform '{sys.platform}'. "
63
+ f"Supported browsers: chrome, chromium, brave. Or use --history-db for a custom path."
64
+ )
65
+ return root / profile / "History"
66
+
67
+
68
+ def copy_history_db(src: Path) -> Path:
69
+ """Copy the history database to a temporary location to avoid locking issues."""
70
+ if not src.exists():
71
+ raise FileNotFoundError(f"History DB not found: {src}")
72
+ tmp_dir = Path(tempfile.mkdtemp())
73
+ tmp_file = tmp_dir / "History"
74
+ shutil.copy2(src, tmp_file)
75
+ return tmp_file
76
+
77
+
78
+ def read_visits(db_path: Path) -> List[Dict[str, Any]]:
79
+ """Read visit records from the SQLite history database."""
80
+ # Use read-only mode to prevent database modification
81
+ con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
82
+ try:
83
+ cur = con.execute(
84
+ """
85
+ SELECT
86
+ visits.id AS visit_id,
87
+ visits.visit_time,
88
+ urls.url,
89
+ urls.title
90
+ FROM visits
91
+ JOIN urls ON urls.id = visits.url
92
+ WHERE visits.visit_time > 0
93
+ ORDER BY visits.visit_time ASC
94
+ """
95
+ )
96
+ rows = [
97
+ {"visit_id": r[0], "visit_time": r[1], "url": r[2], "title": r[3] or ""}
98
+ for r in cur.fetchall()
99
+ ]
100
+ finally:
101
+ con.close()
102
+ return rows
103
+
104
+
105
+ def chrome_time_to_datetime(value: int) -> datetime:
106
+ """Convert Chrome WebKit microsecond timestamp to timezone-aware datetime."""
107
+ return CHROME_EPOCH + timedelta(microseconds=value)
108
+
109
+
110
+ def convert_times(visits: List[Dict[str, Any]], tz: ZoneInfo) -> List[Dict[str, Any]]:
111
+ """Convert raw timestamps to local datetimes and dates."""
112
+ out = []
113
+ for v in visits:
114
+ dt_utc = chrome_time_to_datetime(v["visit_time"])
115
+ dt_local = dt_utc.astimezone(tz)
116
+ out.append({**v, "timestamp_local": dt_local, "date_local": dt_local.date()})
117
+ return out
118
+
119
+
120
+ def filter_visits(
121
+ visits: List[Dict[str, Any]],
122
+ ignore_schemes: Tuple[str, ...] = INTERNAL_SCHEMES,
123
+ date_from: Optional[date] = None,
124
+ date_to: Optional[date] = None,
125
+ ) -> List[Dict[str, Any]]:
126
+ """Filter out internal browser schemes and restrict to date range."""
127
+ out = []
128
+ for v in visits:
129
+ url = v["url"]
130
+ if any(url.startswith(s) for s in ignore_schemes):
131
+ continue
132
+ d = v["date_local"]
133
+ if date_from and d < date_from:
134
+ continue
135
+ if date_to and d > date_to:
136
+ continue
137
+ out.append(v)
138
+ return out
139
+
140
+
141
+ def compute_blocks(day_visits: List[Dict[str, Any]], gap_minutes: int) -> List[Dict[str, Any]]:
142
+ """Cluster visits into active blocks separated by inactivity gaps."""
143
+ if not day_visits:
144
+ return []
145
+ threshold = timedelta(minutes=gap_minutes)
146
+ blocks = []
147
+ block_start = day_visits[0]["timestamp_local"]
148
+ block_end = day_visits[0]["timestamp_local"]
149
+ block_count = 1
150
+
151
+ for v in day_visits[1:]:
152
+ ts = v["timestamp_local"]
153
+ if ts - block_end <= threshold:
154
+ block_end = ts
155
+ block_count += 1
156
+ else:
157
+ blocks.append({"start": block_start, "end": block_end, "n_visits": block_count})
158
+ block_start = ts
159
+ block_end = ts
160
+ block_count = 1
161
+
162
+ blocks.append({"start": block_start, "end": block_end, "n_visits": block_count})
163
+ return blocks
164
+
165
+
166
+ def group_daily(
167
+ visits: List[Dict[str, Any]], gap_minutes: int
168
+ ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
169
+ """Group visits by day and compute daily summaries and block intervals."""
170
+ by_day: Dict[date, List[Dict[str, Any]]] = {}
171
+ for v in visits:
172
+ by_day.setdefault(v["date_local"], []).append(v)
173
+
174
+ daily_rows = []
175
+ block_rows = []
176
+
177
+ for d in sorted(by_day):
178
+ day_visits = sorted(by_day[d], key=lambda x: x["timestamp_local"])
179
+ blocks = compute_blocks(day_visits, gap_minutes)
180
+
181
+ first_seen = day_visits[0]["timestamp_local"]
182
+ last_seen = day_visits[-1]["timestamp_local"]
183
+ gross_span = (last_seen - first_seen).total_seconds() / 3600
184
+
185
+ active_secs = sum(
186
+ (b["end"] - b["start"]).total_seconds() for b in blocks
187
+ )
188
+ active_hours = active_secs / 3600
189
+
190
+ daily_rows.append(
191
+ {
192
+ "date": d.isoformat(),
193
+ "first_seen": first_seen.strftime("%H:%M"),
194
+ "last_seen": last_seen.strftime("%H:%M"),
195
+ "gross_span_hours": round(gross_span, 2),
196
+ "active_block_hours": round(active_hours, 2),
197
+ "n_blocks": len(blocks),
198
+ "n_visits": len(day_visits),
199
+ "_first_dt": first_seen,
200
+ "_last_dt": last_seen,
201
+ }
202
+ )
203
+
204
+ for b in blocks:
205
+ dur = (b["end"] - b["start"]).total_seconds() / 3600
206
+ block_rows.append(
207
+ {
208
+ "date": d.isoformat(),
209
+ "block_start": b["start"].strftime("%H:%M"),
210
+ "block_end": b["end"].strftime("%H:%M"),
211
+ "duration_hours": round(dur, 2),
212
+ "n_visits": b["n_visits"],
213
+ }
214
+ )
215
+
216
+ return daily_rows, block_rows
217
+
218
+
219
+ def write_csv(rows: List[Dict[str, Any]], path: Path, fieldnames: List[str]) -> None:
220
+ """Write rows to a CSV file."""
221
+ with open(path, "w", newline="", encoding="utf-8") as f:
222
+ writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
223
+ writer.writeheader()
224
+ writer.writerows(rows)
225
+
226
+
227
+ def write_markdown(daily_rows: List[Dict[str, Any]], path: Path) -> None:
228
+ """Write daily summaries to a Markdown table file."""
229
+ lines = [
230
+ "# Chrome Work Hours Summary",
231
+ "",
232
+ f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
233
+ "",
234
+ "| Date | First | Last | Gross h | Active h | Blocks | Visits |",
235
+ "|------|-------|------|---------|----------|--------|--------|",
236
+ ]
237
+ for r in daily_rows:
238
+ lines.append(
239
+ f"| {r['date']} | {r['first_seen']} | {r['last_seen']} "
240
+ f"| {r['gross_span_hours']:.2f} | {r['active_block_hours']:.2f} "
241
+ f"| {r['n_blocks']} | {r['n_visits']} |"
242
+ )
243
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
244
+
245
+
246
+ def emit_warnings(daily_rows: List[Dict[str, Any]]) -> List[str]:
247
+ """Check daily rows for potential anomalies and return a list of warning strings."""
248
+ warn_late_h, warn_late_m = WARN_LATEST_HOUR_MINUTE
249
+ warnings = []
250
+ for r in daily_rows:
251
+ d_str = r["date"]
252
+ if r["n_visits"] < WARN_MIN_VISITS_PER_DAY:
253
+ warnings.append(f"WARN {d_str}: only {r['n_visits']} visits (< {WARN_MIN_VISITS_PER_DAY})")
254
+ if r["gross_span_hours"] > WARN_MAX_SPAN_HOURS:
255
+ warnings.append(f"WARN {d_str}: gross span {r['gross_span_hours']:.1f} h > {WARN_MAX_SPAN_HOURS} h")
256
+ first_h = r["_first_dt"].hour
257
+ if first_h < WARN_EARLIEST_HOUR:
258
+ warnings.append(f"WARN {d_str}: first visit at {r['first_seen']} (before 0{WARN_EARLIEST_HOUR}:00)")
259
+ last_dt = r["_last_dt"]
260
+ if (last_dt.hour, last_dt.minute) > (warn_late_h, warn_late_m):
261
+ warnings.append(f"WARN {d_str}: last visit at {r['last_seen']} (after {warn_late_h}:{warn_late_m:02d})")
262
+ return warnings
263
+
264
+
265
+ def extract_workhours(
266
+ browser: str = "chrome",
267
+ profile: str = DEFAULT_PROFILE,
268
+ history_db: Optional[Union[str, Path]] = None,
269
+ date_from: Optional[date] = None,
270
+ date_to: Optional[date] = None,
271
+ timezone_str: str = DEFAULT_TIMEZONE,
272
+ gap_minutes: int = DEFAULT_GAP_MINUTES,
273
+ ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[str]]:
274
+ """
275
+ Programmatic API to extract work hours from browser history.
276
+
277
+ Returns:
278
+ Tuple of (daily_rows, block_rows, warnings)
279
+ """
280
+ tz = ZoneInfo(timezone_str)
281
+ src = resolve_history_path(browser, profile, history_db)
282
+ tmp_db = copy_history_db(src)
283
+
284
+ try:
285
+ raw = read_visits(tmp_db)
286
+ converted = convert_times(raw, tz)
287
+ filtered = filter_visits(converted, date_from=date_from, date_to=date_to)
288
+
289
+ if not filtered:
290
+ return [], [], []
291
+
292
+ daily_rows, block_rows = group_daily(filtered, gap_minutes)
293
+ warnings = emit_warnings(daily_rows)
294
+ return daily_rows, block_rows, warnings
295
+ finally:
296
+ try:
297
+ shutil.rmtree(tmp_db.parent, ignore_errors=True)
298
+ except OSError:
299
+ pass
300
+
301
+
302
+ def parse_args() -> argparse.Namespace:
303
+ p = argparse.ArgumentParser(
304
+ description="Infer workday start/end from Chrome browser history."
305
+ )
306
+ p.add_argument("--profile", default=DEFAULT_PROFILE, help="Chrome profile name (default: Default)")
307
+ p.add_argument("--browser", default="chrome", choices=["chrome", "chromium", "brave"])
308
+ p.add_argument("--history-db", default=None, help="Direct path to History SQLite file")
309
+ p.add_argument("--from", dest="date_from", default=None, help="Start date YYYY-MM-DD (inclusive)")
310
+ p.add_argument("--to", dest="date_to", default=None, help="End date YYYY-MM-DD (inclusive)")
311
+ p.add_argument("--timezone", default=DEFAULT_TIMEZONE, help="IANA timezone (default: Europe/Amsterdam)")
312
+ p.add_argument("--gap-minutes", type=int, default=DEFAULT_GAP_MINUTES, help="Inactivity gap threshold in minutes")
313
+ p.add_argument("--out", default=DEFAULT_OUT_DIR, help="Output directory")
314
+ p.add_argument("--markdown", action="store_true", help="Also write daily_summary.md")
315
+ return p.parse_args()
316
+
317
+
318
+ def main() -> None:
319
+ args = parse_args()
320
+
321
+ date_from = (
322
+ datetime.strptime(args.date_from, "%Y-%m-%d").date() if args.date_from else None
323
+ )
324
+ date_to = (
325
+ datetime.strptime(args.date_to, "%Y-%m-%d").date() if args.date_to else None
326
+ )
327
+
328
+ try:
329
+ daily_rows, block_rows, warnings = extract_workhours(
330
+ browser=args.browser,
331
+ profile=args.profile,
332
+ history_db=args.history_db,
333
+ date_from=date_from,
334
+ date_to=date_to,
335
+ timezone_str=args.timezone,
336
+ gap_minutes=args.gap_minutes,
337
+ )
338
+
339
+ if not daily_rows:
340
+ print("No visits found after filtering. Nothing to do.")
341
+ return
342
+
343
+ print(f"Days summarised: {len(daily_rows)}")
344
+ for w in warnings:
345
+ print(f" {w}")
346
+
347
+ out_dir = Path(args.out)
348
+ out_dir.mkdir(parents=True, exist_ok=True)
349
+
350
+ daily_path = out_dir / "daily_summary.csv"
351
+ blocks_path = out_dir / "blocks.csv"
352
+
353
+ write_csv(
354
+ daily_rows,
355
+ daily_path,
356
+ ["date", "first_seen", "last_seen", "gross_span_hours", "active_block_hours", "n_blocks", "n_visits"],
357
+ )
358
+ write_csv(
359
+ block_rows,
360
+ blocks_path,
361
+ ["date", "block_start", "block_end", "duration_hours", "n_visits"],
362
+ )
363
+
364
+ if args.markdown:
365
+ md_path = out_dir / "daily_summary.md"
366
+ write_markdown(daily_rows, md_path)
367
+ print(f"Markdown written to: {md_path}")
368
+
369
+ print(f"Output written to: {out_dir.resolve()}")
370
+ print(f" {daily_path.name}: {len(daily_rows)} days")
371
+ print(f" {blocks_path.name}: {len(block_rows)} blocks")
372
+
373
+ except Exception as e:
374
+ print(f"Error: {e}", file=sys.stderr)
375
+ sys.exit(1)
376
+
377
+
378
+ if __name__ == "__main__":
379
+ main()
@@ -0,0 +1,147 @@
1
+ """Merge multiple blocks.csv files, collapsing overlapping intervals."""
2
+
3
+ import argparse
4
+ import csv
5
+ import sys
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Union
9
+
10
+
11
+ def parse_dt(d: str, t: str) -> datetime:
12
+ """Parse date and time strings into a datetime object."""
13
+ return datetime.strptime(f"{d} {t}", "%Y-%m-%d %H:%M")
14
+
15
+
16
+ def load_blocks(paths: List[Path]) -> List[Dict[str, Any]]:
17
+ """Load block records from a list of CSV files."""
18
+ rows = []
19
+ for p in paths:
20
+ with open(p, newline="", encoding="utf-8") as f:
21
+ for row in csv.DictReader(f):
22
+ rows.append({
23
+ "date": row["date"],
24
+ "start": parse_dt(row["date"], row["block_start"]),
25
+ "end": parse_dt(row["date"], row["block_end"]),
26
+ "n_visits": int(row["n_visits"]),
27
+ })
28
+ return rows
29
+
30
+
31
+ def merge_intervals(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
32
+ """Sort and merge overlapping or adjacent block intervals."""
33
+ if not rows:
34
+ return []
35
+
36
+ rows.sort(key=lambda r: (r["date"], r["start"]))
37
+ merged = []
38
+ cur = dict(rows[0])
39
+
40
+ for r in rows[1:]:
41
+ # extend if overlapping or immediately adjacent (same minute)
42
+ if r["date"] == cur["date"] and r["start"] <= cur["end"]:
43
+ cur["end"] = max(cur["end"], r["end"])
44
+ cur["n_visits"] += r["n_visits"]
45
+ else:
46
+ merged.append(cur)
47
+ cur = dict(r)
48
+
49
+ merged.append(cur)
50
+ return merged
51
+
52
+
53
+ def write_blocks(rows: List[Dict[str, Any]], path: Path) -> None:
54
+ """Write merged blocks to a CSV file."""
55
+ fieldnames = ["date", "block_start", "block_end", "duration_hours", "n_visits"]
56
+ with open(path, "w", newline="", encoding="utf-8") as f:
57
+ w = csv.DictWriter(f, fieldnames=fieldnames)
58
+ w.writeheader()
59
+ for r in rows:
60
+ dur = (r["end"] - r["start"]).total_seconds() / 3600
61
+ w.writerow({
62
+ "date": r["date"],
63
+ "block_start": r["start"].strftime("%H:%M"),
64
+ "block_end": r["end"].strftime("%H:%M"),
65
+ "duration_hours": round(dur, 2),
66
+ "n_visits": r["n_visits"],
67
+ })
68
+
69
+
70
+ def write_daily(rows: List[Dict[str, Any]], path: Path) -> None:
71
+ """Derive and write daily summaries from merged blocks to a CSV file."""
72
+ by_day: Dict[str, List[Dict[str, Any]]] = {}
73
+ for r in rows:
74
+ by_day.setdefault(r["date"], []).append(r)
75
+
76
+ fieldnames = ["date", "first_seen", "last_seen", "gross_span_hours",
77
+ "active_block_hours", "n_blocks", "n_visits"]
78
+ with open(path, "w", newline="", encoding="utf-8") as f:
79
+ w = csv.DictWriter(f, fieldnames=fieldnames)
80
+ w.writeheader()
81
+ for d in sorted(by_day):
82
+ day = by_day[d]
83
+ first = min(r["start"] for r in day)
84
+ last = max(r["end"] for r in day)
85
+ gross = (last - first).total_seconds() / 3600
86
+ active = sum((r["end"] - r["start"]).total_seconds() for r in day) / 3600
87
+ w.writerow({
88
+ "date": d,
89
+ "first_seen": first.strftime("%H:%M"),
90
+ "last_seen": last.strftime("%H:%M"),
91
+ "gross_span_hours": round(gross, 2),
92
+ "active_block_hours": round(active, 2),
93
+ "n_blocks": len(day),
94
+ "n_visits": sum(r["n_visits"] for r in day),
95
+ })
96
+
97
+
98
+ def merge_blocks(
99
+ files: List[Union[str, Path]],
100
+ out_blocks: Union[str, Path] = "merged_blocks.csv",
101
+ out_daily: Union[str, Path] = "merged_daily.csv",
102
+ ) -> None:
103
+ """
104
+ Programmatic API to merge multiple blocks.csv files and write outputs.
105
+ """
106
+ paths = [Path(f) for f in files]
107
+ missing = [p for p in paths if not p.exists()]
108
+ if missing:
109
+ raise FileNotFoundError(f"Files not found: {', '.join(str(m) for m in missing)}")
110
+
111
+ rows = load_blocks(paths)
112
+ merged = merge_intervals(rows)
113
+
114
+ write_blocks(merged, Path(out_blocks))
115
+ write_daily(merged, Path(out_daily))
116
+
117
+
118
+ def parse_args() -> argparse.Namespace:
119
+ p = argparse.ArgumentParser(
120
+ description="Merge blocks.csv files, collapsing overlapping time intervals."
121
+ )
122
+ p.add_argument("files", nargs="+", help="blocks.csv files to merge")
123
+ p.add_argument("--out-blocks", default="merged_blocks.csv")
124
+ p.add_argument("--out-daily", default="merged_daily.csv",
125
+ help="Derive a daily summary from merged blocks")
126
+ return p.parse_args()
127
+
128
+
129
+ def main() -> None:
130
+ args = parse_args()
131
+
132
+ try:
133
+ merge_blocks(
134
+ files=args.files,
135
+ out_blocks=args.out_blocks,
136
+ out_daily=args.out_daily,
137
+ )
138
+ print(f"Successfully merged {len(args.files)} file(s).")
139
+ print(f"Written: {args.out_blocks}")
140
+ print(f"Written: {args.out_daily}")
141
+ except Exception as e:
142
+ print(f"Error: {e}", file=sys.stderr)
143
+ sys.exit(1)
144
+
145
+
146
+ if __name__ == "__main__":
147
+ main()
@@ -0,0 +1,377 @@
1
+ """Work-hours timelines + histograms in dark Soilytix style."""
2
+
3
+ import argparse
4
+ import csv
5
+ import statistics
6
+ import sys
7
+ from datetime import date, datetime, timedelta
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Tuple, Union
10
+
11
+ # ── Soilytix dark palette ─────────────────────────────────────────────────────
12
+
13
+ C = {
14
+ "fig_bg": "#111614",
15
+ "axes_bg": "#161e1b",
16
+ "text": "#fdfefc",
17
+ "muted": "#837e75",
18
+ "dimmed": "#7d7a75",
19
+ "border": "#2a3a34",
20
+ "grid": "#1d2b26",
21
+ "primary": "#00ff87", # mint — weekly line
22
+ "secondary": "#86eb22", # lime — daily bars / histograms
23
+ "red": "#b14117",
24
+ "blue": "#1a4f8a",
25
+ "cyan": "#0f7a65",
26
+ "weekend": "#0c1410", # darker strip for Sat/Sun
27
+ }
28
+
29
+ STAT_COLORS = {
30
+ "min": C["blue"],
31
+ "max": C["red"],
32
+ "mean": C["cyan"],
33
+ "median": C["muted"],
34
+ }
35
+
36
+ # ── Load ──────────────────────────────────────────────────────────────────────
37
+
38
+ def load_daily(path: Path, date_from: Optional[date], date_to: Optional[date]) -> List[Dict[str, Any]]:
39
+ """Load daily summary data from CSV file and filter by date range."""
40
+ rows = []
41
+ with open(path, newline="", encoding="utf-8") as f:
42
+ for row in csv.DictReader(f):
43
+ d = datetime.strptime(row["date"], "%Y-%m-%d").date()
44
+ if date_from and d < date_from:
45
+ continue
46
+ if date_to and d > date_to:
47
+ continue
48
+ rows.append({
49
+ "date": d,
50
+ "weekday": d.weekday(), # 0=Mon … 6=Sun
51
+ "iso_week": d.isocalendar()[:2], # (year, week)
52
+ "active_hours": float(row["active_block_hours"]),
53
+ })
54
+ return rows
55
+
56
+ # ── Aggregation ───────────────────────────────────────────────────────────────
57
+
58
+ def _to_dt(d: date) -> datetime:
59
+ return datetime(d.year, d.month, d.day)
60
+
61
+
62
+ def daily_series(rows: List[Dict[str, Any]], weekdays_only: bool) -> Tuple[List[datetime], List[float]]:
63
+ """Extract daily dates and active hours series."""
64
+ out = [(r["date"], r["active_hours"]) for r in rows
65
+ if not weekdays_only or r["weekday"] < 5]
66
+ if not out:
67
+ return [], []
68
+ dates, hours = zip(*out)
69
+ return [_to_dt(d) for d in dates], list(hours)
70
+
71
+
72
+ def _iso_week_wednesday(year: int, week: int) -> date:
73
+ jan4 = date(year, 1, 4)
74
+ monday_w1 = jan4 - timedelta(days=jan4.weekday())
75
+ return monday_w1 + timedelta(weeks=week - 1, days=2)
76
+
77
+
78
+ def weekly_series(rows: List[Dict[str, Any]], weekdays_only: bool) -> Tuple[List[datetime], List[float]]:
79
+ """Aggregate daily active hours into weekly totals."""
80
+ by_week: Dict[Tuple[int, int], float] = {}
81
+ for r in rows:
82
+ if weekdays_only and r["weekday"] >= 5:
83
+ continue
84
+ by_week[r["iso_week"]] = by_week.get(r["iso_week"], 0.0) + r["active_hours"]
85
+ items = sorted(by_week.items())
86
+ if not items:
87
+ return [], []
88
+ dates = [_to_dt(_iso_week_wednesday(y, w)) for (y, w), _ in items]
89
+ hours = [v for _, v in items]
90
+ return dates, hours
91
+
92
+
93
+ def hist_values(rows: List[Dict[str, Any]], weekdays_only: bool, is_weekly: bool) -> List[float]:
94
+ """Get active hours list for histogram plotting."""
95
+ _, hours = (weekly_series if is_weekly else daily_series)(rows, weekdays_only)
96
+ return hours
97
+
98
+
99
+ def date_range_all(rows: List[Dict[str, Any]]) -> List[datetime]:
100
+ """Generate every calendar day from the first to the last row date."""
101
+ first, last = rows[0]["date"], rows[-1]["date"]
102
+ out, d = [], first
103
+ while d <= last:
104
+ out.append(_to_dt(d))
105
+ d += timedelta(days=1)
106
+ return out
107
+
108
+ # ── Theme helpers ─────────────────────────────────────────────────────────────
109
+
110
+ def apply_style() -> None:
111
+ """Apply the custom Soilytix dark stylesheet to matplotlib."""
112
+ import matplotlib.pyplot as plt
113
+ plt.rcParams.update({
114
+ "font.family": "sans-serif",
115
+ "font.sans-serif": ["Inter", "Aptos", "Helvetica Neue", "Arial"],
116
+ "figure.facecolor": C["fig_bg"],
117
+ "axes.facecolor": C["axes_bg"],
118
+ "text.color": C["text"],
119
+ "axes.labelcolor": C["muted"],
120
+ "xtick.color": C["muted"],
121
+ "ytick.color": C["muted"],
122
+ "axes.edgecolor": C["border"],
123
+ "grid.color": C["grid"],
124
+ "grid.linewidth": 0.5,
125
+ "legend.facecolor": C["axes_bg"],
126
+ "legend.edgecolor": C["border"],
127
+ "legend.labelcolor": C["text"],
128
+ })
129
+
130
+
131
+ def dress_axes(ax: Any, xlabel: str = "", ylabel: str = "") -> None:
132
+ """Style axes spines, ticks, labels, and gridlines."""
133
+ ax.set_facecolor(C["axes_bg"])
134
+ for spine in ax.spines.values():
135
+ spine.set_edgecolor(C["border"])
136
+ ax.tick_params(colors=C["muted"], labelsize=8)
137
+ ax.set_xlabel(xlabel, fontsize=8.5, color=C["muted"])
138
+ ax.set_ylabel(ylabel, fontsize=8.5, color=C["muted"])
139
+ ax.set_axisbelow(True)
140
+ ax.grid(axis="y", zorder=1)
141
+
142
+
143
+ def shade_weekends(ax: Any, all_dts: List[datetime]) -> None:
144
+ """Shade weekend days with a darker background strip."""
145
+ for dt in all_dts:
146
+ if dt.weekday() >= 5: # 5=Sat, 6=Sun
147
+ ax.axvspan(dt - timedelta(hours=12), dt + timedelta(hours=12),
148
+ color=C["weekend"], alpha=1.0, zorder=0)
149
+
150
+
151
+ def stat_box(ax: Any, values: List[float]) -> None:
152
+ """Draw vertical lines for min, max, mean, median and display a stats box."""
153
+ if not values:
154
+ return
155
+ stats = {
156
+ "min": min(values),
157
+ "max": max(values),
158
+ "mean": statistics.mean(values),
159
+ "median": statistics.median(values),
160
+ }
161
+ for label, val in stats.items():
162
+ ax.axvline(val, color=STAT_COLORS[label], linewidth=1.3,
163
+ linestyle="--", alpha=0.9, zorder=4)
164
+ box_text = "\n".join(f"{k:<7}{v:.2f} h" for k, v in stats.items())
165
+ ax.text(
166
+ 0.97, 0.97, box_text,
167
+ transform=ax.transAxes, ha="right", va="top",
168
+ fontsize=8, fontfamily="monospace", color=C["text"],
169
+ bbox=dict(boxstyle="round,pad=0.4", facecolor=C["fig_bg"],
170
+ edgecolor=C["primary"], linewidth=0.9, alpha=0.95),
171
+ zorder=5,
172
+ )
173
+
174
+ # ── Timeline ──────────────────────────────────────────────────────────────────
175
+
176
+ def plot_timeline(ax: Any, rows: List[Dict[str, Any]]) -> None:
177
+ """Plot daily active hours as bars and weekly active hours as a line."""
178
+ import matplotlib.dates as mdates
179
+ import matplotlib.ticker as ticker
180
+ import matplotlib.pyplot as plt
181
+
182
+ all_dts = date_range_all(rows)
183
+ shade_weekends(ax, all_dts)
184
+
185
+ daily_dates, daily_hours = daily_series(rows, weekdays_only=False)
186
+ weekly_dates, weekly_hours = weekly_series(rows, weekdays_only=False)
187
+
188
+ # Daily bars (lime, left axis)
189
+ ax.bar(daily_dates, daily_hours,
190
+ color=C["secondary"], alpha=0.78, width=0.72, zorder=2,
191
+ label="Daily active h")
192
+ dress_axes(ax, ylabel="Active hours (day)")
193
+
194
+ # Weekly line (mint, right axis)
195
+ ax_w = ax.twinx()
196
+ ax_w.plot(weekly_dates, weekly_hours,
197
+ color=C["primary"], linewidth=2.0, marker="o", markersize=5,
198
+ markerfacecolor=C["primary"], markeredgecolor=C["fig_bg"],
199
+ markeredgewidth=1.4, zorder=3, label="Weekly total h")
200
+ ax_w.tick_params(colors=C["primary"], labelsize=8)
201
+ ax_w.set_ylabel("Active hours (week)", fontsize=8.5, color=C["primary"])
202
+ ax_w.spines["right"].set_edgecolor(C["primary"])
203
+ for s in ("top", "left", "bottom"):
204
+ ax_w.spines[s].set_visible(False)
205
+ ax_w.grid(False)
206
+ ax_w.yaxis.set_major_locator(ticker.MaxNLocator(integer=False, nbins=5))
207
+
208
+ # x-axis date formatting
209
+ span_days = (rows[-1]["date"] - rows[0]["date"]).days
210
+ if span_days <= 60:
211
+ ax.xaxis.set_major_locator(mdates.WeekdayLocator(byweekday=mdates.MO))
212
+ ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d"))
213
+ elif span_days <= 180:
214
+ ax.xaxis.set_major_locator(mdates.WeekdayLocator(byweekday=mdates.MO, interval=2))
215
+ ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d"))
216
+ else:
217
+ ax.xaxis.set_major_locator(mdates.MonthLocator())
218
+ ax.xaxis.set_major_formatter(mdates.DateFormatter("%b '%y"))
219
+ plt.setp(ax.xaxis.get_majorticklabels(), rotation=35, ha="right", fontsize=8)
220
+
221
+ # x range with a half-day margin
222
+ ax.set_xlim(all_dts[0] - timedelta(hours=12), all_dts[-1] + timedelta(hours=12))
223
+ ax.yaxis.set_major_locator(ticker.MaxNLocator(integer=False, nbins=6))
224
+
225
+ # Legend combining both axes
226
+ h1, l1 = ax.get_legend_handles_labels()
227
+ h2, l2 = ax_w.get_legend_handles_labels()
228
+ ax.legend(h1 + h2, l1 + l2, loc="upper left", fontsize=8.5,
229
+ facecolor=C["axes_bg"], edgecolor=C["border"])
230
+
231
+ ax.set_title(
232
+ "Active hours — daily bars + weekly total (weekends shaded)",
233
+ fontsize=10.5, color=C["text"], pad=8, fontweight="bold",
234
+ )
235
+
236
+ # ── Histogram ─────────────────────────────────────────────────────────────────
237
+
238
+ HIST_PANELS = [
239
+ ("Daily — all days", False, False, "Active hours (day)"),
240
+ ("Daily — weekdays only", True, False, "Active hours (day)"),
241
+ ("Weekly — all days", False, True, "Active hours (week)"),
242
+ ("Weekly — weekdays only", True, True, "Active hours (week)"),
243
+ ]
244
+
245
+
246
+ def plot_histogram(ax: Any, values: List[float],
247
+ title: str, xlabel: str, bins: int) -> None:
248
+ """Plot a single histogram panel with statistics."""
249
+ import matplotlib.ticker as ticker
250
+ if not values:
251
+ ax.text(0.5, 0.5, "no data", ha="center", va="center",
252
+ transform=ax.transAxes, color=C["muted"])
253
+ ax.set_title(title, fontsize=9.5, color=C["text"], pad=6, fontweight="bold")
254
+ return
255
+
256
+ n_bins = min(bins, max(5, len(values) // 2))
257
+ ax.hist(values, bins=n_bins,
258
+ color=C["secondary"], edgecolor=C["axes_bg"],
259
+ linewidth=0.6, alpha=0.88, zorder=2)
260
+ dress_axes(ax, xlabel=xlabel, ylabel="Count")
261
+ ax.set_title(title, fontsize=9.5, color=C["text"], pad=6, fontweight="bold")
262
+ ax.yaxis.set_major_locator(ticker.MaxNLocator(integer=True))
263
+ ax.text(0.03, 0.97, f"n={len(values)}",
264
+ transform=ax.transAxes, ha="left", va="top",
265
+ fontsize=8, color=C["muted"])
266
+ stat_box(ax, values)
267
+
268
+ # ── Figure assembly ───────────────────────────────────────────────────────────
269
+
270
+ def build_figure(
271
+ rows: List[Dict[str, Any]],
272
+ date_from: Optional[date],
273
+ bins: int,
274
+ out_stem: str,
275
+ show: bool = False,
276
+ ) -> None:
277
+ """Assemble the timeline and histograms into a single multi-panel figure."""
278
+ import matplotlib.pyplot as plt
279
+ from matplotlib.gridspec import GridSpec
280
+
281
+ apply_style()
282
+
283
+ fig = plt.figure(figsize=(16, 16))
284
+ gs = GridSpec(
285
+ 3, 2, figure=fig,
286
+ height_ratios=[1.8, 1.4, 1.4],
287
+ hspace=0.55, wspace=0.32,
288
+ left=0.07, right=0.94, top=0.94, bottom=0.05,
289
+ )
290
+
291
+ subtitle = f"after {date_from}" if date_from else "all available history"
292
+ fig.suptitle(
293
+ f"Soilytix · Work Hours · {subtitle}",
294
+ fontsize=14, fontweight="bold", color=C["text"], y=0.975,
295
+ )
296
+
297
+ # Timeline (full width)
298
+ ax_tl = fig.add_subplot(gs[0, :])
299
+ plot_timeline(ax_tl, rows)
300
+
301
+ # 2 × 2 histograms
302
+ positions = [(1, 0), (1, 1), (2, 0), (2, 1)]
303
+ for (r, c), (title, wd_only, is_weekly, xlabel) in zip(positions, HIST_PANELS):
304
+ ax = fig.add_subplot(gs[r, c])
305
+ plot_histogram(ax, hist_values(rows, wd_only, is_weekly), title, xlabel, bins)
306
+
307
+ # Export
308
+ out_dir = Path(out_stem).parent
309
+ if out_dir != Path("."):
310
+ out_dir.mkdir(parents=True, exist_ok=True)
311
+
312
+ for ext in ("png", "pdf"):
313
+ out = f"{out_stem}.{ext}"
314
+ fig.savefig(out, dpi=150, bbox_inches="tight",
315
+ facecolor=fig.get_facecolor())
316
+ print(f"Saved: {out}")
317
+
318
+ if show:
319
+ plt.show()
320
+ else:
321
+ plt.close(fig)
322
+
323
+
324
+ def plot_workhours(
325
+ csv_path: Union[str, Path],
326
+ date_from: Optional[date] = None,
327
+ date_to: Optional[date] = None,
328
+ bins: int = 15,
329
+ out_stem: str = "workhours_plot",
330
+ show: bool = False,
331
+ ) -> None:
332
+ """
333
+ Programmatic API to plot work hours from a daily summary CSV file.
334
+ """
335
+ rows = load_daily(Path(csv_path), date_from, date_to)
336
+ if not rows:
337
+ raise ValueError("No data found in the specified date range.")
338
+
339
+ build_figure(rows, date_from, bins, out_stem, show=show)
340
+
341
+
342
+ def parse_args() -> argparse.Namespace:
343
+ p = argparse.ArgumentParser(
344
+ description="Work-hour timelines and histograms in dark Soilytix style."
345
+ )
346
+ p.add_argument("csv", help="daily summary CSV")
347
+ p.add_argument("--from", dest="date_from", default="2026-02-22")
348
+ p.add_argument("--to", dest="date_to", default=None)
349
+ p.add_argument("--bins", type=int, default=15)
350
+ p.add_argument("--out", default="workhours_plot",
351
+ help="Output file stem — .png and .pdf are appended")
352
+ p.add_argument("--show", action="store_true", help="Display the plot window")
353
+ return p.parse_args()
354
+
355
+
356
+ def main() -> None:
357
+ args = parse_args()
358
+
359
+ date_from = datetime.strptime(args.date_from, "%Y-%m-%d").date() if args.date_from else None
360
+ date_to = datetime.strptime(args.date_to, "%Y-%m-%d").date() if args.date_to else None
361
+
362
+ try:
363
+ plot_workhours(
364
+ csv_path=args.csv,
365
+ date_from=date_from,
366
+ date_to=date_to,
367
+ bins=args.bins,
368
+ out_stem=args.out,
369
+ show=args.show,
370
+ )
371
+ except Exception as e:
372
+ print(f"Error: {e}", file=sys.stderr)
373
+ sys.exit(1)
374
+
375
+
376
+ if __name__ == "__main__":
377
+ main()
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: chrome-time-clock
3
+ Version: 0.1.0
4
+ Summary: Infer workday start and end from Chrome, Chromium or Brave history, then plot or merge the hours.
5
+ Author-email: Maurice Frank <maurice.frank@posteo.de>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/morris-frank/chrome-time-clock
8
+ Project-URL: Issues, https://github.com/morris-frank/chrome-time-clock/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: matplotlib>=3.5.0
20
+ Dynamic: license-file
21
+
22
+ <img src="brand/icon/icon-chrome-time-clock-on-obsidian-1024.png" align="left" width="128" hspace="16" alt="chrome-time-clock icon">
23
+
24
+ <h3>chrome-time-clock</h3>
25
+
26
+ <p>
27
+ <sub>YOUR BROWSER ALREADY KEPT THE TIMESHEET</sub>
28
+ <br>
29
+ <strong>Infer workday start and end from Chrome, Chromium or Brave history, then plot or merge the hours.</strong>
30
+ <br>
31
+ <br>
32
+ <a href="https://pypi.org/project/chrome-time-clock/"><img src="https://img.shields.io/pypi/v/chrome-time-clock?style=flat-square&amp;color=3775A9&amp;logo=pypi&amp;logoColor=white&amp;labelColor=2D2825" alt="PyPI version"></a>
33
+ <img src="https://img.shields.io/badge/python-%E2%89%A53.9-D78A7A?style=flat-square&amp;labelColor=2D2825" alt="Python 3.9+">
34
+ <img src="https://img.shields.io/badge/browsers-Chrome%20%C2%B7%20Chromium%20%C2%B7%20Brave-D78A7A?style=flat-square&amp;labelColor=2D2825" alt="Chrome, Chromium, Brave">
35
+ <img src="https://img.shields.io/badge/OS-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux-7E9688?style=flat-square&amp;labelColor=2D2825" alt="macOS, Windows, Linux">
36
+ <img src="https://img.shields.io/badge/license-MIT-7E9688?style=flat-square&amp;labelColor=2D2825" alt="MIT license">
37
+ </p>
38
+
39
+ <br clear="left">
40
+
41
+ ```sh
42
+ uv tool install chrome-time-clock # or: pipx install chrome-time-clock
43
+ chrome-time-clock extract --out ./export --markdown # history → daily_summary.csv (+ .md)
44
+ chrome-time-clock plot ./export/daily_summary.csv # timeline + histograms, .png and .pdf
45
+ chrome-time-clock merge laptop.csv desktop.csv # union of blocks across machines
46
+ ```
47
+
48
+ Everything runs locally against a copy of the browser's History SQLite file; nothing leaves the machine.
49
+
50
+ ## Extract
51
+
52
+ Groups history visits into active blocks, split wherever the gap exceeds `--gap-minutes`, and reports each day's first and last block.
53
+
54
+ | Option | Default | |
55
+ |---|---|---|
56
+ | `--browser` | `chrome` | `chrome`, `chromium` or `brave` |
57
+ | `--profile` | `Default` | e.g. `Profile 1` |
58
+ | `--history-db` | — | a History file directly; overrides browser/profile |
59
+ | `--from` / `--to` | — | `YYYY-MM-DD`, inclusive |
60
+ | `--timezone` | `Europe/Amsterdam` | IANA name |
61
+ | `--gap-minutes` | `60` | inactivity that ends a block |
62
+ | `--out` | `./chrome_workhours_export` | CSV output directory |
63
+ | `--markdown` | off | also write `daily_summary.md` |
64
+
65
+ ## Plot
66
+
67
+ Dark-theme timeline and start/end histograms from `daily_summary.csv`.
68
+
69
+ | Option | Default | |
70
+ |---|---|---|
71
+ | `--from` / `--to` | `2026-02-22` / — | `YYYY-MM-DD`, inclusive |
72
+ | `--bins` | `15` | histogram bins |
73
+ | `--out` | `workhours_plot` | file stem; `.png` and `.pdf` appended |
74
+ | `--show` | off | open an interactive window |
75
+
76
+ ## Merge
77
+
78
+ Combines several `blocks.csv` files — one per machine or profile — collapsing overlapping intervals:
79
+
80
+ ```sh
81
+ chrome-time-clock merge a.csv b.csv --out-blocks merged_blocks.csv --out-daily merged_daily.csv
82
+ ```
83
+
84
+ Each subcommand also has a standalone shortcut: `chrome-workhours`, `plot-workhours`, `merge-blocks`.
85
+
86
+ ## Python API
87
+
88
+ ```python
89
+ from datetime import date
90
+ from chrome_time_clock import extract_workhours, plot_workhours, merge_blocks
91
+
92
+ daily, blocks, warnings = extract_workhours(browser="chrome", profile="Default",
93
+ date_from=date(2026, 6, 1), timezone_str="Europe/Amsterdam")
94
+ plot_workhours(csv_path="./export/daily_summary.csv", date_from=date(2026, 6, 1), out_stem="plot")
95
+ merge_blocks(files=["a.csv", "b.csv"], out_blocks="merged_blocks.csv", out_daily="merged_daily.csv")
96
+ ```
@@ -0,0 +1,11 @@
1
+ chrome_time_clock/__init__.py,sha256=LzxYEny3IePm9qtjO37l1wUoeqbiAQRQGbrE_jvsqkQ,385
2
+ chrome_time_clock/cli.py,sha256=7sXBWMumu1LgsoWq-4RaXj0ycUmVBpziI-Vi_nZ_lJs,7844
3
+ chrome_time_clock/extractor.py,sha256=UuKxAXIMm9oLb7Xuq4Cd4q-EOf-dZhNYGG45JCh3yQ0,13569
4
+ chrome_time_clock/merger.py,sha256=l-AkoCH-Ke6AXYqzP7ePlkdm28o34zqKWaeEm6cxh7I,5058
5
+ chrome_time_clock/plotter.py,sha256=Cs3SYP5PxpQTSEy9LS-5VjJh2JRoQfW_42NYzRlvVwE,14664
6
+ chrome_time_clock-0.1.0.dist-info/licenses/LICENSE,sha256=J8UJZvrogVVTgwOaObDrWTucgwAWei_QF9GFevU5iPM,1070
7
+ chrome_time_clock-0.1.0.dist-info/METADATA,sha256=lSaoCzV34Pr7u-kVvHudLp86f3kVCoyt0Ptrx_5UPT0,4406
8
+ chrome_time_clock-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
9
+ chrome_time_clock-0.1.0.dist-info/entry_points.txt,sha256=vJStQzAdvSOUGwQ1C2klX-dbZ4i_g78SxpEHFM5peMA,210
10
+ chrome_time_clock-0.1.0.dist-info/top_level.txt,sha256=Q7496CuWQqXp40Oyd9GI2BUbxceLRnm45Ji0ywhj0l4,18
11
+ chrome_time_clock-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ chrome-time-clock = chrome_time_clock.cli:main
3
+ chrome-workhours = chrome_time_clock.extractor:main
4
+ merge-blocks = chrome_time_clock.merger:main
5
+ plot-workhours = chrome_time_clock.plotter:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maurice Frank
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ chrome_time_clock