daytrace 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
daytrace-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daytrace contributors
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,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: daytrace
3
+ Version: 0.1.0
4
+ Summary: Deterministic daily summaries from ActivityWatch
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Dist: aw-client>=0.5.15,<0.6
8
+ Requires-Dist: tzlocal>=5.4,<6
9
+ Requires-Dist: tzdata>=2026.3 ; sys_platform == 'win32'
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+
13
+ # Daytrace
14
+
15
+ Daytrace is a privacy-first desktop utility that turns a person's day into a
16
+ searchable text journal. Screen, microphone, keyboard activity, and system
17
+ context are independent opt-ins. Captured media is processed ephemerally and
18
+ discarded; the durable record contains text and structured metadata only.
19
+
20
+ This repository now includes a headless ActivityWatch summary prototype. The
21
+ broader cross-platform capture application remains in the product and
22
+ architecture planning phase.
23
+
24
+ ## ActivityWatch summary prototype
25
+
26
+ The first executable prototype is a Python CLI that reads a selected day from
27
+ an already-running ActivityWatch instance and produces deterministic Markdown.
28
+ It does not copy raw events or call an LLM.
29
+
30
+ Prerequisites: install and run ActivityWatch, then install `uv`. On Windows:
31
+
32
+ ```powershell
33
+ winget install --id=astral-sh.uv -e
34
+ ```
35
+
36
+ From a source checkout:
37
+
38
+ ```powershell
39
+ git switch codex/activitywatch-integration-research
40
+ uv sync
41
+ uv run daytrace activitywatch --date 2026-09-10 --output summary.md
42
+ ```
43
+
44
+ Filter the report to records containing a project name:
45
+
46
+ ```powershell
47
+ uv run daytrace activitywatch --date 2026-09-10 --project daytrace --output summary.md
48
+ ```
49
+
50
+ The resulting `summary.md` contains an overview, a chronological timeline, and
51
+ application totals. A day with no matching activity is still a successful
52
+ report and contains `No matching activity.`
53
+
54
+ After the package is published, the equivalent one-off command will be:
55
+
56
+ ```powershell
57
+ uvx daytrace activitywatch --date 2026-09-10 --output summary.md
58
+ ```
59
+
60
+ Python callers—including a future second-brain integration—can use the same
61
+ deterministic renderer directly:
62
+
63
+ ```python
64
+ from datetime import date
65
+
66
+ from daytrace.activitywatch import summarize_day
67
+
68
+ markdown = summarize_day(date(2026, 9, 10), project="daytrace")
69
+ ```
70
+
71
+ Daytrace reads ActivityWatch through `http://127.0.0.1:5600` by default. It
72
+ does not retain images, audio, video, full browser URLs, or a second copy of
73
+ ActivityWatch events. Use `--server` for another ActivityWatch endpoint and
74
+ `--timezone` for an explicit IANA timezone such as `America/Los_Angeles`.
75
+
76
+ ## Product decisions
77
+
78
+ - Local-first: the database and processing stay on the user's computer unless
79
+ the user explicitly configures a cloud provider or export destination.
80
+ - All capture sources start off and require separate, informed consent.
81
+ - Tauri 2 and Rust are the recommended desktop stack, with a small React/Vite
82
+ interface that normally stays hidden behind a tray icon.
83
+ - Accessibility text is preferred over screenshots. OCR is a fallback, and
84
+ image buffers are destroyed after extraction.
85
+ - Microphone audio is held only in bounded memory, segmented with voice
86
+ activity detection, transcribed, and discarded.
87
+ - Keyboard capture defaults to activity signals only, not key content. An
88
+ advanced typed-text mode is a later, separately consented feature with
89
+ password and deny-list suppression.
90
+ - SQLite, B-tree time dimensions, and FTS5 provide the hierarchical timeline
91
+ and full-text search. Captured media and vector embeddings are not stored.
92
+ - The first release supports BYOK and local providers. A hosted inference
93
+ gateway can later fund the project without locking users into the service.
94
+
95
+ ## Plan
96
+
97
+ - [Implementation plan](docs/IMPLEMENTATION_PLAN.md)
98
+ - [Architecture](docs/ARCHITECTURE.md)
99
+ - [Data model and search](docs/DATA_MODEL.md)
100
+ - [Privacy and security](docs/PRIVACY_SECURITY.md)
101
+ - [Product and UX specification](docs/PRODUCT_UX.md)
102
+ - [Screenpipe reuse audit](docs/SCREENPIPE_REUSE.md)
103
+ - [ActivityWatch integration research](docs/ACTIVITYWATCH_INTEGRATION_RESEARCH.md)
104
+
105
+ ## Proposed repository shape
106
+
107
+ ```text
108
+ apps/
109
+ desktop/ Tauri window, tray, onboarding, settings
110
+ crates/
111
+ daytrace-core/ orchestration, policies, domain events
112
+ daytrace-capture/ platform-neutral capture traits
113
+ daytrace-platform-*/ macOS, Windows, and Linux adapters
114
+ daytrace-extract/ accessibility, OCR, VAD, transcription
115
+ daytrace-store/ SQLite migrations, FTS, retention
116
+ daytrace-summary/ segmentation and provider-neutral summaries
117
+ daytrace-sync/ deterministic export and GitHub sync
118
+ daytrace-secrets/ OS credential-store abstraction
119
+ extensions/
120
+ browser/ optional Chromium/Firefox tab metadata bridge
121
+ docs/
122
+ ```
123
+
124
+ ## Target outcome
125
+
126
+ The v1 release is a signed, auto-updating macOS, Windows, and Linux desktop app
127
+ that can run for an eight-hour day without retaining raw media, recover cleanly
128
+ from sleep and device changes, search a local journal, and create an editable
129
+ end-of-day summary. See the implementation plan for measurable release gates.
@@ -0,0 +1,117 @@
1
+ # Daytrace
2
+
3
+ Daytrace is a privacy-first desktop utility that turns a person's day into a
4
+ searchable text journal. Screen, microphone, keyboard activity, and system
5
+ context are independent opt-ins. Captured media is processed ephemerally and
6
+ discarded; the durable record contains text and structured metadata only.
7
+
8
+ This repository now includes a headless ActivityWatch summary prototype. The
9
+ broader cross-platform capture application remains in the product and
10
+ architecture planning phase.
11
+
12
+ ## ActivityWatch summary prototype
13
+
14
+ The first executable prototype is a Python CLI that reads a selected day from
15
+ an already-running ActivityWatch instance and produces deterministic Markdown.
16
+ It does not copy raw events or call an LLM.
17
+
18
+ Prerequisites: install and run ActivityWatch, then install `uv`. On Windows:
19
+
20
+ ```powershell
21
+ winget install --id=astral-sh.uv -e
22
+ ```
23
+
24
+ From a source checkout:
25
+
26
+ ```powershell
27
+ git switch codex/activitywatch-integration-research
28
+ uv sync
29
+ uv run daytrace activitywatch --date 2026-09-10 --output summary.md
30
+ ```
31
+
32
+ Filter the report to records containing a project name:
33
+
34
+ ```powershell
35
+ uv run daytrace activitywatch --date 2026-09-10 --project daytrace --output summary.md
36
+ ```
37
+
38
+ The resulting `summary.md` contains an overview, a chronological timeline, and
39
+ application totals. A day with no matching activity is still a successful
40
+ report and contains `No matching activity.`
41
+
42
+ After the package is published, the equivalent one-off command will be:
43
+
44
+ ```powershell
45
+ uvx daytrace activitywatch --date 2026-09-10 --output summary.md
46
+ ```
47
+
48
+ Python callers—including a future second-brain integration—can use the same
49
+ deterministic renderer directly:
50
+
51
+ ```python
52
+ from datetime import date
53
+
54
+ from daytrace.activitywatch import summarize_day
55
+
56
+ markdown = summarize_day(date(2026, 9, 10), project="daytrace")
57
+ ```
58
+
59
+ Daytrace reads ActivityWatch through `http://127.0.0.1:5600` by default. It
60
+ does not retain images, audio, video, full browser URLs, or a second copy of
61
+ ActivityWatch events. Use `--server` for another ActivityWatch endpoint and
62
+ `--timezone` for an explicit IANA timezone such as `America/Los_Angeles`.
63
+
64
+ ## Product decisions
65
+
66
+ - Local-first: the database and processing stay on the user's computer unless
67
+ the user explicitly configures a cloud provider or export destination.
68
+ - All capture sources start off and require separate, informed consent.
69
+ - Tauri 2 and Rust are the recommended desktop stack, with a small React/Vite
70
+ interface that normally stays hidden behind a tray icon.
71
+ - Accessibility text is preferred over screenshots. OCR is a fallback, and
72
+ image buffers are destroyed after extraction.
73
+ - Microphone audio is held only in bounded memory, segmented with voice
74
+ activity detection, transcribed, and discarded.
75
+ - Keyboard capture defaults to activity signals only, not key content. An
76
+ advanced typed-text mode is a later, separately consented feature with
77
+ password and deny-list suppression.
78
+ - SQLite, B-tree time dimensions, and FTS5 provide the hierarchical timeline
79
+ and full-text search. Captured media and vector embeddings are not stored.
80
+ - The first release supports BYOK and local providers. A hosted inference
81
+ gateway can later fund the project without locking users into the service.
82
+
83
+ ## Plan
84
+
85
+ - [Implementation plan](docs/IMPLEMENTATION_PLAN.md)
86
+ - [Architecture](docs/ARCHITECTURE.md)
87
+ - [Data model and search](docs/DATA_MODEL.md)
88
+ - [Privacy and security](docs/PRIVACY_SECURITY.md)
89
+ - [Product and UX specification](docs/PRODUCT_UX.md)
90
+ - [Screenpipe reuse audit](docs/SCREENPIPE_REUSE.md)
91
+ - [ActivityWatch integration research](docs/ACTIVITYWATCH_INTEGRATION_RESEARCH.md)
92
+
93
+ ## Proposed repository shape
94
+
95
+ ```text
96
+ apps/
97
+ desktop/ Tauri window, tray, onboarding, settings
98
+ crates/
99
+ daytrace-core/ orchestration, policies, domain events
100
+ daytrace-capture/ platform-neutral capture traits
101
+ daytrace-platform-*/ macOS, Windows, and Linux adapters
102
+ daytrace-extract/ accessibility, OCR, VAD, transcription
103
+ daytrace-store/ SQLite migrations, FTS, retention
104
+ daytrace-summary/ segmentation and provider-neutral summaries
105
+ daytrace-sync/ deterministic export and GitHub sync
106
+ daytrace-secrets/ OS credential-store abstraction
107
+ extensions/
108
+ browser/ optional Chromium/Firefox tab metadata bridge
109
+ docs/
110
+ ```
111
+
112
+ ## Target outcome
113
+
114
+ The v1 release is a signed, auto-updating macOS, Windows, and Linux desktop app
115
+ that can run for an eight-hour day without retaining raw media, recover cleanly
116
+ from sleep and device changes, search a local journal, and create an editable
117
+ end-of-day summary. See the implementation plan for measurable release gates.
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.12.13,<0.13"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "daytrace"
7
+ version = "0.1.0"
8
+ description = "Deterministic daily summaries from ActivityWatch"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = [
14
+ "aw-client>=0.5.15,<0.6",
15
+ "tzlocal>=5.4,<6",
16
+ "tzdata>=2026.3; sys_platform == 'win32'",
17
+ ]
18
+
19
+ [project.scripts]
20
+ daytrace = "daytrace.cli:entrypoint"
21
+
22
+ [dependency-groups]
23
+ dev = ["pytest>=9.1,<10"]
24
+
25
+ [tool.pytest.ini_options]
26
+ addopts = "-ra"
27
+ testpaths = ["tests"]
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.12.13,<0.13"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "daytrace"
7
+ version = "0.1.0"
8
+ description = "Deterministic daily summaries from ActivityWatch"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = [
14
+ "aw-client>=0.5.15,<0.6",
15
+ "tzlocal>=5.4,<6",
16
+ "tzdata>=2026.3; sys_platform == 'win32'",
17
+ ]
18
+
19
+ [project.scripts]
20
+ daytrace = "daytrace.cli:entrypoint"
21
+
22
+ [dependency-groups]
23
+ dev = ["pytest>=9.1,<10"]
24
+
25
+ [tool.pytest.ini_options]
26
+ addopts = "-ra"
27
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from daytrace.cli import entrypoint
2
+
3
+ entrypoint()
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Callable
5
+ from datetime import date, timezone
6
+
7
+ from daytrace.markdown import render_markdown
8
+ from daytrace.normalize import SUPPORTED_BUCKET_TYPES, normalize_events
9
+ from daytrace.report import build_report
10
+ from daytrace.source import ActivitySource, AwClientSource
11
+ from daytrace.time import resolve_day
12
+ from daytrace.transform import filter_project, merge_adjacent, remove_afk
13
+
14
+
15
+ DEFAULT_SERVER = "http://127.0.0.1:5600"
16
+
17
+
18
+ def summarize_day(
19
+ day: date,
20
+ project: str | None = None,
21
+ *,
22
+ server: str = DEFAULT_SERVER,
23
+ timezone_name: str | None = None,
24
+ source: ActivitySource | None = None,
25
+ warn: Callable[[str], None] | None = None,
26
+ ) -> str:
27
+ warning = warn or logging.getLogger("daytrace").warning
28
+ window = resolve_day(day, timezone_name)
29
+ activity_source = source or AwClientSource.from_url(server)
30
+ activity_source.get_info()
31
+ buckets = activity_source.list_buckets()
32
+ supported = tuple(
33
+ bucket for bucket in buckets if bucket.type in SUPPORTED_BUCKET_TYPES
34
+ )
35
+ unknown_count = len(buckets) - len(supported)
36
+ if unknown_count:
37
+ noun = "bucket" if unknown_count == 1 else "buckets"
38
+ warning(f"ignored {unknown_count} unsupported ActivityWatch {noun}")
39
+
40
+ records = tuple(
41
+ record
42
+ for bucket in supported
43
+ for record in normalize_events(
44
+ bucket,
45
+ activity_source.get_events(
46
+ bucket.id,
47
+ window.start.astimezone(timezone.utc),
48
+ window.end.astimezone(timezone.utc),
49
+ ),
50
+ window,
51
+ warning,
52
+ )
53
+ )
54
+ transformed = merge_adjacent(filter_project(remove_afk(records), project))
55
+ return render_markdown(build_report(day, window, transformed, project))
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from collections.abc import Sequence
6
+ from datetime import date
7
+ from pathlib import Path
8
+ from typing import NoReturn
9
+ from zoneinfo import ZoneInfoNotFoundError
10
+
11
+ from daytrace.activitywatch import DEFAULT_SERVER, summarize_day
12
+ from daytrace.source import ActivityWatchConnectionError
13
+
14
+
15
+ def _date(value: str) -> date:
16
+ try:
17
+ return date.fromisoformat(value)
18
+ except ValueError as exc:
19
+ raise argparse.ArgumentTypeError("expected YYYY-MM-DD") from exc
20
+
21
+
22
+ def build_parser() -> argparse.ArgumentParser:
23
+ parser = argparse.ArgumentParser(prog="daytrace")
24
+ commands = parser.add_subparsers(dest="command", required=True)
25
+ activitywatch = commands.add_parser(
26
+ "activitywatch", help="summarize ActivityWatch data"
27
+ )
28
+ activitywatch.add_argument("--date", required=True, type=_date)
29
+ activitywatch.add_argument("--project")
30
+ activitywatch.add_argument("--server", default=DEFAULT_SERVER)
31
+ activitywatch.add_argument("--timezone", dest="timezone_name")
32
+ activitywatch.add_argument("--output", type=Path)
33
+ return parser
34
+
35
+
36
+ def main(argv: Sequence[str] | None = None) -> int:
37
+ args = build_parser().parse_args(argv)
38
+ try:
39
+ markdown = summarize_day(
40
+ args.date,
41
+ args.project,
42
+ server=args.server,
43
+ timezone_name=args.timezone_name,
44
+ warn=lambda message: print(f"warning: {message}", file=sys.stderr),
45
+ )
46
+ except ZoneInfoNotFoundError:
47
+ print(f"error: invalid timezone {args.timezone_name!r}", file=sys.stderr)
48
+ return 1
49
+ except ValueError:
50
+ print("error: invalid ActivityWatch server URL", file=sys.stderr)
51
+ return 1
52
+ except ActivityWatchConnectionError:
53
+ print(
54
+ f"error: ActivityWatch is not reachable at {args.server}; "
55
+ "start ActivityWatch or pass --server",
56
+ file=sys.stderr,
57
+ )
58
+ return 1
59
+
60
+ if args.output:
61
+ try:
62
+ lf_markdown = markdown.replace("\r\n", "\n").replace("\r", "\n")
63
+ args.output.write_bytes(lf_markdown.encode("utf-8"))
64
+ except OSError:
65
+ print(
66
+ f"error: could not write output file {str(args.output)!r}",
67
+ file=sys.stderr,
68
+ )
69
+ return 1
70
+ else:
71
+ sys.stdout.write(markdown)
72
+ return 0
73
+
74
+
75
+ def entrypoint() -> NoReturn:
76
+ raise SystemExit(main())
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import re
5
+ from zoneinfo import ZoneInfo
6
+
7
+ from daytrace.models import ActivityRecord, ActivityReport, SourceKind
8
+
9
+
10
+ SOURCE_LABEL = {
11
+ SourceKind.WINDOW: "Current window",
12
+ SourceKind.EDITOR: "Editor",
13
+ SourceKind.BROWSER: "Browser",
14
+ }
15
+
16
+
17
+ def format_duration(seconds: float) -> str:
18
+ minutes = max(1, int((seconds + 30) // 60))
19
+ hours, minutes = divmod(minutes, 60)
20
+ if hours and minutes:
21
+ return f"{hours}h {minutes}m"
22
+ if hours:
23
+ return f"{hours}h"
24
+ return f"{minutes}m"
25
+
26
+
27
+ def _single_line(value: str) -> str:
28
+ return " ".join(value.replace("\r", " ").replace("\n", " ").split())
29
+
30
+
31
+ def _escape(value: str) -> str:
32
+ escaped = html.escape(_single_line(value), quote=False).replace("\\", "\\\\")
33
+ return re.sub(r"([`*_{}\[\]#+|])", r"\\\1", escaped)
34
+
35
+
36
+ def _code(value: str) -> str:
37
+ value = _single_line(value)
38
+ longest = max((len(run) for run in re.findall(r"`+", value)), default=0)
39
+ fence = "`" * (longest + 1)
40
+ padding = " " if value.startswith("`") or value.endswith("`") else ""
41
+ return f"{fence}{padding}{value}{padding}{fence}"
42
+
43
+
44
+ def _basename(value: str | None) -> str | None:
45
+ return value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] if value else None
46
+
47
+
48
+ def _label(record: ActivityRecord) -> str:
49
+ if record.kind is SourceKind.WINDOW:
50
+ values = (record.app, record.title)
51
+ elif record.kind is SourceKind.BROWSER:
52
+ values = (record.url_host, record.title)
53
+ else:
54
+ values = (_basename(record.project), _basename(record.file), record.language)
55
+ label = " — ".join(_escape(value) for value in values if value)
56
+ return label or SOURCE_LABEL[record.kind]
57
+
58
+
59
+ def render_markdown(report: ActivityReport) -> str:
60
+ lines = [f"# Activity summary — {report.day.isoformat()}", ""]
61
+ if report.project is not None:
62
+ lines.append(f"Project filter: {_code(report.project)}")
63
+ lines.extend([f"Timezone: {_code(report.timezone_name)}", "", "## Overview", ""])
64
+ if not report.timeline:
65
+ return "\n".join(lines + ["No matching activity.", ""])
66
+
67
+ active = (
68
+ format_duration(report.active_seconds)
69
+ if report.active_seconds is not None
70
+ else "Unavailable"
71
+ )
72
+ lines.extend(
73
+ [
74
+ f"- Active matched time: {active}",
75
+ f"- Timeline entries: {len(report.timeline)}",
76
+ f"- Sources: {', '.join(SOURCE_LABEL[item] for item in report.sources)}",
77
+ "",
78
+ "## Timeline",
79
+ "",
80
+ ]
81
+ )
82
+ zone = ZoneInfo(report.timezone_name)
83
+ for record in report.timeline:
84
+ start = record.start.astimezone(zone).strftime("%H:%M")
85
+ end = record.end.astimezone(zone).strftime("%H:%M")
86
+ lines.append(
87
+ f"- {start}–{end} ({format_duration(record.duration_seconds)}) — {_label(record)}"
88
+ )
89
+ if report.applications:
90
+ lines.extend(["", "## Applications", ""])
91
+ lines.extend(
92
+ f"- {_escape(item.app)} — {format_duration(item.seconds)}"
93
+ for item in report.applications
94
+ )
95
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import date, datetime
5
+ from enum import StrEnum
6
+ from types import MappingProxyType
7
+ from typing import Mapping
8
+
9
+
10
+ class SourceKind(StrEnum):
11
+ WINDOW = "current-window"
12
+ AFK = "afk"
13
+ EDITOR = "editor"
14
+ BROWSER = "browser"
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class ServerEndpoint:
19
+ protocol: str
20
+ host: str
21
+ port: int
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class ServerInfo:
26
+ version: str
27
+ testing: bool
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class DayWindow:
32
+ timezone_name: str
33
+ start: datetime
34
+ end: datetime
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class RawBucket:
39
+ id: str
40
+ type: str
41
+ client: str
42
+ hostname: str
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class RawEvent:
47
+ id: str
48
+ timestamp: datetime
49
+ duration_seconds: float
50
+ data: Mapping[str, object]
51
+
52
+ def __post_init__(self) -> None:
53
+ object.__setattr__(self, "data", MappingProxyType(dict(self.data)))
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class ActivityRecord:
58
+ event_id: str
59
+ bucket_id: str
60
+ kind: SourceKind
61
+ start: datetime
62
+ end: datetime
63
+ app: str | None = None
64
+ title: str | None = None
65
+ project: str | None = None
66
+ file: str | None = None
67
+ url_host: str | None = None
68
+ language: str | None = None
69
+ status: str | None = None
70
+
71
+ @property
72
+ def duration_seconds(self) -> float:
73
+ return (self.end - self.start).total_seconds()
74
+
75
+ def content_key(self) -> tuple[object, ...]:
76
+ return (
77
+ self.bucket_id,
78
+ self.kind,
79
+ self.app,
80
+ self.title,
81
+ self.project,
82
+ self.file,
83
+ self.url_host,
84
+ self.language,
85
+ self.status,
86
+ )
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class ApplicationTotal:
91
+ app: str
92
+ seconds: float
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class ActivityReport:
97
+ day: date
98
+ timezone_name: str
99
+ project: str | None
100
+ active_seconds: float | None
101
+ sources: tuple[SourceKind, ...]
102
+ timeline: tuple[ActivityRecord, ...]
103
+ applications: tuple[ApplicationTotal, ...]
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Iterable, Mapping
4
+ from datetime import timedelta, timezone
5
+ from urllib.parse import urlsplit
6
+
7
+ from daytrace.models import ActivityRecord, DayWindow, RawBucket, RawEvent, SourceKind
8
+
9
+
10
+ ONE_SECOND = timedelta(seconds=1)
11
+ SUPPORTED_BUCKET_TYPES: Mapping[str, SourceKind] = {
12
+ "currentwindow": SourceKind.WINDOW,
13
+ "afkstatus": SourceKind.AFK,
14
+ "app.editor.activity": SourceKind.EDITOR,
15
+ "web.tab.current": SourceKind.BROWSER,
16
+ }
17
+
18
+
19
+ def _text(data: Mapping[str, object], key: str) -> str | None:
20
+ value = data.get(key)
21
+ return value if isinstance(value, str) and value else None
22
+
23
+
24
+ def _url_host(data: Mapping[str, object]) -> str | None:
25
+ value = _text(data, "url")
26
+ if not value:
27
+ return None
28
+ try:
29
+ return urlsplit(value).hostname
30
+ except ValueError:
31
+ return None
32
+
33
+
34
+ def normalize_events(
35
+ bucket: RawBucket,
36
+ events: Iterable[RawEvent],
37
+ window: DayWindow,
38
+ warn: Callable[[str], None],
39
+ ) -> tuple[ActivityRecord, ...]:
40
+ kind = SUPPORTED_BUCKET_TYPES[bucket.type]
41
+ normalized: list[ActivityRecord] = []
42
+ window_start = window.start.astimezone(timezone.utc)
43
+ window_end = window.end.astimezone(timezone.utc)
44
+
45
+ for event in events:
46
+ if event.timestamp.tzinfo is None:
47
+ warn(
48
+ f"skipped naive timestamp for event {event.id!r} in bucket {bucket.id!r}"
49
+ )
50
+ continue
51
+ raw_start = event.timestamp.astimezone(timezone.utc)
52
+ start = max(raw_start, window_start)
53
+ end = min(raw_start + event.duration_seconds * ONE_SECOND, window_end)
54
+ if end <= start:
55
+ warn(f"skipped non-positive event {event.id!r} in bucket {bucket.id!r}")
56
+ continue
57
+ normalized.append(
58
+ ActivityRecord(
59
+ event_id=event.id,
60
+ bucket_id=bucket.id,
61
+ kind=kind,
62
+ start=start,
63
+ end=end,
64
+ app=_text(event.data, "app"),
65
+ title=_text(event.data, "title"),
66
+ project=_text(event.data, "project"),
67
+ file=_text(event.data, "file"),
68
+ url_host=_url_host(event.data),
69
+ language=_text(event.data, "language"),
70
+ status=_text(event.data, "status"),
71
+ )
72
+ )
73
+
74
+ return tuple(
75
+ sorted(normalized, key=lambda item: (item.start, item.bucket_id, item.event_id))
76
+ )
@@ -0,0 +1,54 @@
1
+ from collections import defaultdict
2
+ from datetime import date
3
+
4
+ from daytrace.models import (
5
+ ActivityRecord,
6
+ ActivityReport,
7
+ ApplicationTotal,
8
+ DayWindow,
9
+ SourceKind,
10
+ )
11
+ from daytrace.transform import partition_window_seconds
12
+
13
+
14
+ SOURCE_ORDER = {
15
+ SourceKind.WINDOW: 0,
16
+ SourceKind.EDITOR: 1,
17
+ SourceKind.BROWSER: 2,
18
+ }
19
+
20
+
21
+ def build_report(
22
+ day: date,
23
+ window: DayWindow,
24
+ records: tuple[ActivityRecord, ...],
25
+ project: str | None,
26
+ ) -> ActivityReport:
27
+ timeline = tuple(
28
+ sorted(records, key=lambda item: (item.start, item.bucket_id, item.event_id))
29
+ )
30
+ allocation = partition_window_seconds(timeline)
31
+ app_seconds: dict[str, float] = defaultdict(float)
32
+ for record, seconds in allocation:
33
+ app_seconds[record.app or "Unknown application"] += seconds
34
+ applications = tuple(
35
+ ApplicationTotal(app, seconds)
36
+ for app, seconds in sorted(
37
+ app_seconds.items(),
38
+ key=lambda item: (-item[1], item[0].casefold(), item[0]),
39
+ )
40
+ )
41
+ sources = tuple(
42
+ sorted({item.kind for item in timeline}, key=SOURCE_ORDER.__getitem__)
43
+ )
44
+ return ActivityReport(
45
+ day=day,
46
+ timezone_name=window.timezone_name,
47
+ project=project,
48
+ active_seconds=sum(item.seconds for item in applications)
49
+ if allocation
50
+ else None,
51
+ sources=sources,
52
+ timeline=timeline,
53
+ applications=applications,
54
+ )
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from datetime import datetime, timedelta
5
+ from typing import Any, Protocol
6
+ from urllib.parse import urlsplit
7
+
8
+ from aw_client import ActivityWatchClient
9
+
10
+ from daytrace.models import RawBucket, RawEvent, ServerEndpoint, ServerInfo
11
+
12
+
13
+ class ActivityWatchConnectionError(RuntimeError):
14
+ """A privacy-safe failure while reading ActivityWatch."""
15
+
16
+
17
+ class ActivitySource(Protocol):
18
+ def get_info(self) -> ServerInfo: ...
19
+
20
+ def list_buckets(self) -> tuple[RawBucket, ...]: ...
21
+
22
+ def get_events(
23
+ self, bucket_id: str, start: datetime, end: datetime
24
+ ) -> tuple[RawEvent, ...]: ...
25
+
26
+
27
+ def parse_server_url(url: str) -> ServerEndpoint:
28
+ parsed = urlsplit(url)
29
+ valid = (
30
+ parsed.scheme in {"http", "https"}
31
+ and parsed.hostname is not None
32
+ and parsed.username is None
33
+ and parsed.password is None
34
+ and parsed.path in {"", "/"}
35
+ and not parsed.query
36
+ and not parsed.fragment
37
+ )
38
+ if not valid:
39
+ raise ValueError("invalid ActivityWatch server URL")
40
+ try:
41
+ port = parsed.port or 5600
42
+ except ValueError as exc:
43
+ raise ValueError("invalid ActivityWatch server URL") from exc
44
+ return ServerEndpoint(parsed.scheme, parsed.hostname, port)
45
+
46
+
47
+ class AwClientSource:
48
+ def __init__(self, client: ActivityWatchClient | Any) -> None:
49
+ self._client = client
50
+
51
+ @classmethod
52
+ def from_url(cls, url: str) -> AwClientSource:
53
+ endpoint = parse_server_url(url)
54
+ client = ActivityWatchClient(
55
+ "daytrace",
56
+ host=endpoint.host,
57
+ port=endpoint.port,
58
+ protocol=endpoint.protocol,
59
+ )
60
+ return cls(client)
61
+
62
+ def get_info(self) -> ServerInfo:
63
+ try:
64
+ info = self._client.get_info()
65
+ return ServerInfo(
66
+ str(info.get("version", "unknown")), bool(info.get("testing"))
67
+ )
68
+ except Exception as exc:
69
+ raise ActivityWatchConnectionError(
70
+ "ActivityWatch info request failed"
71
+ ) from exc
72
+
73
+ def list_buckets(self) -> tuple[RawBucket, ...]:
74
+ try:
75
+ buckets = self._client.get_buckets()
76
+ return tuple(
77
+ RawBucket(
78
+ id=bucket_id,
79
+ type=str(data.get("type", "")),
80
+ client=str(data.get("client", "")),
81
+ hostname=str(data.get("hostname", "")),
82
+ )
83
+ for bucket_id, data in sorted(buckets.items())
84
+ )
85
+ except Exception as exc:
86
+ raise ActivityWatchConnectionError(
87
+ "ActivityWatch bucket request failed"
88
+ ) from exc
89
+
90
+ def get_events(
91
+ self, bucket_id: str, start: datetime, end: datetime
92
+ ) -> tuple[RawEvent, ...]:
93
+ try:
94
+ events: Sequence[Any] = self._client.get_events(
95
+ bucket_id, start=start, end=end
96
+ )
97
+ return tuple(
98
+ RawEvent(
99
+ id=str(event.id),
100
+ timestamp=event.timestamp,
101
+ duration_seconds=(
102
+ event.duration.total_seconds()
103
+ if isinstance(event.duration, timedelta)
104
+ else float(event.duration)
105
+ ),
106
+ data=event.data,
107
+ )
108
+ for event in events
109
+ )
110
+ except Exception as exc:
111
+ raise ActivityWatchConnectionError(
112
+ f"ActivityWatch event request failed for bucket {bucket_id!r}"
113
+ ) from exc
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date, datetime, time, timedelta
4
+ from zoneinfo import ZoneInfo
5
+
6
+ from tzlocal import get_localzone_name
7
+
8
+ from daytrace.models import DayWindow
9
+
10
+
11
+ def resolve_day(day: date, timezone_name: str | None = None) -> DayWindow:
12
+ name = timezone_name or get_localzone_name()
13
+ zone = ZoneInfo(name)
14
+ start = datetime.combine(day, time.min, zone)
15
+ end = datetime.combine(day + timedelta(days=1), time.min, zone)
16
+ return DayWindow(timezone_name=name, start=start, end=end)
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from collections.abc import Iterable
5
+ from dataclasses import replace
6
+ from datetime import datetime, timedelta
7
+
8
+ from daytrace.models import ActivityRecord, SourceKind
9
+
10
+
11
+ def _sort(records: Iterable[ActivityRecord]) -> tuple[ActivityRecord, ...]:
12
+ return tuple(
13
+ sorted(records, key=lambda item: (item.start, item.bucket_id, item.event_id))
14
+ )
15
+
16
+
17
+ def _union(
18
+ intervals: Iterable[tuple[datetime, datetime]],
19
+ ) -> list[tuple[datetime, datetime]]:
20
+ merged: list[tuple[datetime, datetime]] = []
21
+ for start, end in sorted(intervals):
22
+ if not merged or start > merged[-1][1]:
23
+ merged.append((start, end))
24
+ else:
25
+ merged[-1] = (merged[-1][0], max(merged[-1][1], end))
26
+ return merged
27
+
28
+
29
+ def _subtract_interval(
30
+ fragments: list[tuple[datetime, datetime]],
31
+ away_start: datetime,
32
+ away_end: datetime,
33
+ ) -> list[tuple[datetime, datetime]]:
34
+ output: list[tuple[datetime, datetime]] = []
35
+ for start, end in fragments:
36
+ if away_end <= start or away_start >= end:
37
+ output.append((start, end))
38
+ continue
39
+ if start < away_start:
40
+ output.append((start, away_start))
41
+ if away_end < end:
42
+ output.append((away_end, end))
43
+ return output
44
+
45
+
46
+ def remove_afk(records: Iterable[ActivityRecord]) -> tuple[ActivityRecord, ...]:
47
+ items = tuple(records)
48
+ away = _union(
49
+ (item.start, item.end)
50
+ for item in items
51
+ if item.kind is SourceKind.AFK and (item.status or "").casefold() == "afk"
52
+ )
53
+ output: list[ActivityRecord] = []
54
+ for item in items:
55
+ if item.kind is SourceKind.AFK:
56
+ continue
57
+ fragments = [(item.start, item.end)]
58
+ for away_start, away_end in away:
59
+ fragments = _subtract_interval(fragments, away_start, away_end)
60
+ output.extend(replace(item, start=start, end=end) for start, end in fragments)
61
+ return _sort(output)
62
+
63
+
64
+ def filter_project(
65
+ records: Iterable[ActivityRecord], project: str | None
66
+ ) -> tuple[ActivityRecord, ...]:
67
+ if project is None:
68
+ return _sort(records)
69
+ needle = project.casefold()
70
+ return _sort(
71
+ item
72
+ for item in records
73
+ if any(
74
+ needle in value.casefold()
75
+ for value in (
76
+ item.project,
77
+ item.app,
78
+ item.title,
79
+ item.file,
80
+ item.url_host,
81
+ item.bucket_id,
82
+ )
83
+ if value
84
+ )
85
+ )
86
+
87
+
88
+ def merge_adjacent(
89
+ records: Iterable[ActivityRecord],
90
+ maximum_gap: timedelta = timedelta(seconds=60),
91
+ ) -> tuple[ActivityRecord, ...]:
92
+ by_bucket: dict[str, list[ActivityRecord]] = defaultdict(list)
93
+ for item in _sort(records):
94
+ bucket_records = by_bucket[item.bucket_id]
95
+ if (
96
+ bucket_records
97
+ and bucket_records[-1].content_key() == item.content_key()
98
+ and item.start - bucket_records[-1].end <= maximum_gap
99
+ ):
100
+ bucket_records[-1] = replace(
101
+ bucket_records[-1], end=max(bucket_records[-1].end, item.end)
102
+ )
103
+ else:
104
+ bucket_records.append(item)
105
+ return _sort(
106
+ item for bucket_records in by_bucket.values() for item in bucket_records
107
+ )
108
+
109
+
110
+ def partition_window_seconds(
111
+ records: Iterable[ActivityRecord],
112
+ ) -> tuple[tuple[ActivityRecord, float], ...]:
113
+ windows = tuple(item for item in records if item.kind is SourceKind.WINDOW)
114
+ boundaries = sorted({point for item in windows for point in (item.start, item.end)})
115
+ totals: dict[ActivityRecord, float] = defaultdict(float)
116
+ for start, end in zip(boundaries, boundaries[1:]):
117
+ candidates = [item for item in windows if item.start < end and item.end > start]
118
+ if candidates:
119
+ winner = min(candidates, key=lambda item: (item.bucket_id, item.event_id))
120
+ totals[winner] += (end - start).total_seconds()
121
+ return tuple(
122
+ sorted(totals.items(), key=lambda pair: (pair[0].bucket_id, pair[0].event_id))
123
+ )