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.
- chrome_time_clock/__init__.py +17 -0
- chrome_time_clock/cli.py +228 -0
- chrome_time_clock/extractor.py +379 -0
- chrome_time_clock/merger.py +147 -0
- chrome_time_clock/plotter.py +377 -0
- chrome_time_clock-0.1.0.dist-info/METADATA +96 -0
- chrome_time_clock-0.1.0.dist-info/RECORD +11 -0
- chrome_time_clock-0.1.0.dist-info/WHEEL +5 -0
- chrome_time_clock-0.1.0.dist-info/entry_points.txt +5 -0
- chrome_time_clock-0.1.0.dist-info/licenses/LICENSE +21 -0
- chrome_time_clock-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
chrome_time_clock/cli.py
ADDED
|
@@ -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&color=3775A9&logo=pypi&logoColor=white&labelColor=2D2825" alt="PyPI version"></a>
|
|
33
|
+
<img src="https://img.shields.io/badge/python-%E2%89%A53.9-D78A7A?style=flat-square&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&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&labelColor=2D2825" alt="macOS, Windows, Linux">
|
|
36
|
+
<img src="https://img.shields.io/badge/license-MIT-7E9688?style=flat-square&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,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
|