From 9fec08ebc5913dc2f8291c85c7d3ffa8edaacde9 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 21 Oct 2026 15:54:30 +0000 Subject: [PATCH 1/2] docs(examples): add AI-built local interfaces guide and capability map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'AI-generated local interfaces' section with a copyable prompt that generates a standalone HTML dashboard, starting from a synthetic fixture before touching real data - Prompt explicitly prohibits external network access and specifies read-only API operations; distinguishes code generation from data access - Add testing checklist (synthetic fixture, DevTools network check, empty range) - Add custom visualization embedding instructions (experimental) with warning about origin trust - Add capability map table: desktop activity, AFK, rule-based categories, Android/iOS, project attribution, sync — corrects common overstatements (rule-based categories are not automatic AI understanding) Motivated by ActivityWatch/docs#186 and Erik's email re: AI-built personal interfaces (Message-ID CAN-QjnUEivyuWyhCtDsqRzifFucnTwx0ctYFTo2fNJ85ndrq+g) Git-Session-Id: 2a769ab9-933c-41ed-8845-f1475f4f0c19 --- src/examples/agents-and-ai.rst | 128 +++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/src/examples/agents-and-ai.rst b/src/examples/agents-and-ai.rst index 5aa1773..805e08a 100644 --- a/src/examples/agents-and-ai.rst +++ b/src/examples/agents-and-ai.rst @@ -191,6 +191,134 @@ ActivityWatch workflow. Before uploading data: * keep the exact sent payload in your own notes if you need auditability * use a shorter time range than you would use for local analysis +AI-generated local interfaces +----------------------------- + +ActivityWatch's REST API lets you build custom dashboards and visualizations +without changing the core UI. An AI assistant can generate the code; your data +stays local throughout. + +**What to ask for.** Use a prompt like the one below. It starts from a synthetic +event schema so the generated page works before you connect real data, and it +restricts all network access to the local ActivityWatch server: + +.. code-block:: text + + Build a single-file HTML dashboard that reads ActivityWatch data. + + Constraints — follow these exactly: + - All network requests MUST go to http://localhost:5666/api/0/ only. + No CDN imports, no analytics, no external fonts, no telemetry. + - All JavaScript must be inline in the HTML file. No npm, no build step. + - The page must work with SYNTHETIC data first (hardcoded fixture below) + and optionally connect to the live API via a checkbox or button. + - Use only read-only API operations (GET requests). No POST, PUT, or DELETE. + - Do not store, forward, or log any real ActivityWatch events. + + Synthetic fixture (use this until the user enables live data): + { + "hostname": "my-machine", + "start": "2024-01-15T08:00:00", + "end": "2024-01-15T12:00:00", + "categories": [ + {"name": ["Coding"], "seconds": 7200}, + {"name": ["Communication"], "seconds": 1800}, + {"name": ["Uncategorized"], "seconds": 600} + ], + "apps": [ + {"app": "Code", "seconds": 5400}, + {"app": "Firefox", "seconds": 2400} + ], + "active_seconds": 10200, + "afk_seconds": 600 + } + + When live data is enabled, read `hostname`, `start`, `end` from the URL + query parameters (?hostname=…&start=…&end=…) and fetch from: + GET http://localhost:5666/api/0/buckets/ + to discover bucket IDs, then query the canonical activity endpoint at: + POST http://localhost:5666/api/0/query/ + with the time range. Show totals by category and by app, active time, + and AFK time. Show a note that "read-only" is not a server-enforced + boundary — it is a convention this page follows. + +This generates a page that: + +* renders against known data immediately (no ActivityWatch dependency at first) +* fetches from ``localhost:5666`` when live mode is enabled +* makes no outbound requests during code generation or when run +* handles empty responses, AFK filtering, and timezone-aware timestamps + +.. note:: + + Code generation and data access are separate. When you ask an AI to write + this page, the model only sees your prompt and synthetic fixture — not your + real ActivityWatch data. Real data is only accessed later, locally, when + *you* open the generated file in a browser pointed at your running AW server. + +Testing the generated page +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Before using real data, verify the generated page against its synthetic fixture: + +1. Open the file directly in a browser (``file://``). It should render the + fixture categories and totals without any network requests. +2. Open browser DevTools → Network. Reload. Confirm no requests go outside + ``localhost:5666``. +3. Enable live mode. DevTools should show only requests to ``localhost:5666``. +4. Try an empty time range (start = end). The page should show zero totals, not + an error. + +Using the page as a custom visualization +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +ActivityWatch's web UI can embed custom pages as an iframe tab. This feature is +**experimental**. To use it: + +1. Place the generated HTML file in ``/custom_vis//``. +2. Open ActivityWatch at ``http://localhost:5666``. +3. Navigate to the custom visualizations tab and select ````. + +The iframe receives ``hostname``, ``start``, and ``end`` as URL parameters so it +automatically follows the selected time range. + +.. warning:: + + Custom visualizations run in the ActivityWatch server's origin. A malicious + page could read your data or make API calls. Only use pages you wrote or + reviewed. + +Capability map +-------------- + +ActivityWatch covers the local data collection layer. Before building a custom +interface, confirm which features are available in your installed version: + +.. list-table:: + :widths: 40 60 + :header-rows: 1 + + * - Feature + - Notes + * - Desktop activity (window titles, app names) + - Collected by ``aw-watcher-window``; titles available but sensitive + * - AFK / active time + - Collected by ``aw-watcher-afk``; used in canonical queries + * - Rule-based categories + - Set in the web UI; applied at query time; not automatic AI inference + * - Android activity + - Collected by the Android app; synced via :doc:`../features/syncing` + * - iOS activity + - Not available + * - Automatic project attribution + - Not built-in; rule-based only unless you add custom processing + * - Seamless multi-device sync + - Sync is manual/scheduled; see :doc:`../features/syncing` for current status + +Rule-based categories are not AI-generated labels. They match patterns you +define against app names and window titles. Describing them as automatic +AI understanding overstates what is collected. + Related docs ------------ From 81f49559f6ccc6b892af16e9853d33919931067b Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 23 Oct 2026 02:07:19 +0000 Subject: [PATCH 2/2] docs(examples): verify offline AI dashboard with canonical synthetic data Git-Session-Id: 5fc58a24-9fed-4d3e-8eac-bc1c2eb2e0cf --- src/examples/agents-and-ai.rst | 177 ++++++++++----- src/examples/ai-dashboard-summary.py | 121 ++++++++++ src/examples/ai-dashboard.html | 316 +++++++++++++++++++++++++++ src/examples/verify-ai-dashboard.py | 283 ++++++++++++++++++++++++ 4 files changed, 839 insertions(+), 58 deletions(-) create mode 100644 src/examples/ai-dashboard-summary.py create mode 100644 src/examples/ai-dashboard.html create mode 100644 src/examples/verify-ai-dashboard.py diff --git a/src/examples/agents-and-ai.rst b/src/examples/agents-and-ai.rst index 805e08a..6ff4d4e 100644 --- a/src/examples/agents-and-ai.rst +++ b/src/examples/agents-and-ai.rst @@ -194,93 +194,154 @@ ActivityWatch workflow. Before uploading data: AI-generated local interfaces ----------------------------- -ActivityWatch's REST API lets you build custom dashboards and visualizations -without changing the core UI. An AI assistant can generate the code; your data -stays local throughout. +ActivityWatch's existing collection and query tools can support a different +presentation without rebuilding the tracker. Customization complements improving +the default UI; it should not be necessary to get a usable activity summary. -**What to ask for.** Use a prompt like the one below. It starts from a synthetic -event schema so the generated page works before you connect real data, and it -restricts all network access to the local ActivityWatch server: +The smallest integration is an **offline HTML viewer of a local summary**. +Generate code from synthetic data, test it, then optionally load a bounded summary +from your own server. The viewer needs no API connection, CORS changes or build +step. This is a desktop, single-host example, not a multi-device timeline merger. -.. code-block:: text - - Build a single-file HTML dashboard that reads ActivityWatch data. +**What to ask for.** Give your assistant only this prompt and synthetic fixture, +not access to real ActivityWatch events. A locally running agent can still use a +cloud model or tools that upload files; inspect its configuration first: - Constraints — follow these exactly: - - All network requests MUST go to http://localhost:5666/api/0/ only. - No CDN imports, no analytics, no external fonts, no telemetry. - - All JavaScript must be inline in the HTML file. No npm, no build step. - - The page must work with SYNTHETIC data first (hardcoded fixture below) - and optionally connect to the live API via a checkbox or button. - - Use only read-only API operations (GET requests). No POST, PUT, or DELETE. - - Do not store, forward, or log any real ActivityWatch events. +.. code-block:: text - Synthetic fixture (use this until the user enables live data): + Build a single-file HTML dashboard with inline JavaScript and CSS. + Render the synthetic summary below first. Add a local JSON file picker + (FileReader) for another summary in the same schema, never an API fetch. + Show categories, apps, active/AFK/tracked seconds, hostname and the range + with its explicit timezone offsets. Show zero totals for empty data. + Keep hosts separate; never sum overlapping devices as personal active time. + Reject negative/nonfinite durations and invalid/reversed/offset-free dates. + Treat loaded labels as untrusted text, not HTML. + No network requests, imports, CDN, external fonts, analytics or telemetry. + No storage, forwarding or logging of loaded data. No npm or build step. + Add a CSP meta tag with default-src 'none'; connect-src 'none'; + script-src 'unsafe-inline'; style-src 'unsafe-inline'. + + Synthetic fixture (use this before loading a local summary): { "hostname": "my-machine", - "start": "2024-01-15T08:00:00", - "end": "2024-01-15T12:00:00", + "start": "2024-01-15T08:00:00+00:00", + "end": "2024-01-15T12:00:00+00:00", "categories": [ {"name": ["Coding"], "seconds": 7200}, {"name": ["Communication"], "seconds": 1800}, {"name": ["Uncategorized"], "seconds": 600} ], "apps": [ - {"app": "Code", "seconds": 5400}, - {"app": "Firefox", "seconds": 2400} + {"app": "Code", "seconds": 7200}, + {"app": "Firefox", "seconds": 1800}, + {"app": "Terminal", "seconds": 600} ], - "active_seconds": 10200, - "afk_seconds": 600 + "active_seconds": 9600, + "afk_seconds": 600, + "tracked_seconds": 10200 } - When live data is enabled, read `hostname`, `start`, `end` from the URL - query parameters (?hostname=…&start=…&end=…) and fetch from: - GET http://localhost:5666/api/0/buckets/ - to discover bucket IDs, then query the canonical activity endpoint at: - POST http://localhost:5666/api/0/query/ - with the time range. Show totals by category and by app, active time, - and AFK time. Show a note that "read-only" is not a server-enforced - boundary — it is a convention this page follows. +Download the :download:`tested example HTML ` for a reference +implementation. Save it locally and open it with your browser (``file://``). +The initial sample is synthetic; it does not describe your actual activity. +A prompt is not a guarantee: review and test any newly generated variation too. -This generates a page that: +Optional: load your own local summary +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -* renders against known data immediately (no ActivityWatch dependency at first) -* fetches from ``localhost:5666`` when live mode is enabled -* makes no outbound requests during code generation or when run -* handles empty responses, AFK filtering, and timezone-aware timestamps +Use Python 3.11 or newer and install ``aw-client``, then download +:download:`ai-dashboard-summary.py ` to that environment. +Discover your bucket suffix with ``aw-client buckets`` and select **one desktop +hostname** with both ``aw-watcher-window_HOSTNAME`` and +``aw-watcher-afk_HOSTNAME`` buckets. With your existing AW server listening on +loopback port 5666, run: -.. note:: +.. code-block:: sh - Code generation and data access are separate. When you ask an AI to write - this page, the model only sees your prompt and synthetic fixture — not your - real ActivityWatch data. Real data is only accessed later, locally, when - *you* open the generated file in a browser pointed at your running AW server. + python3 ai-dashboard-summary.py HOSTNAME \ + 2024-01-15T08:00:00+00:00 2024-01-15T12:00:00+00:00 > summary.json + +Replace the sample dates and hostname. Use explicit offsets (or ``Z``); for a +local day across a daylight-saving transition, the two offsets can differ. +The exporter applies canonical desktop AFK filtering and category rules from +that server (or the client's bundled defaults if no rules are configured), then +clips intervals to the requested range before aggregation. +It does not export titles or URLs. Category/app names can still be sensitive; +review ``summary.json`` locally and load it using the HTML file picker, **not by +pasting it back into the code-generation chat**. + +``tracked_seconds`` means observed window-event duration, not the entire selected +wall-clock range. AFK is observed AFK overlapping those windows. A gap in AFK +coverage is unknown, not automatically active or AFK; ``unknown_seconds`` in the +JSON records the nonnegative remainder. For a non-empty range, missing buckets +produce an error rather than a misleading zero. This assumes normal, +nonoverlapping window events per host. Canonical processing can bridge short +heartbeat gaps; do not treat these estimates as precise time accounting. +Synced buckets for other hosts are not included: compare hosts separately, do not +add their active durations without a separately defined overlap/precedence policy. + +The exporter uses GET for bucket/settings discovery and ``POST /api/0/query/`` +for read-only calculations. POST is required by the query API; it does not imply +an event write. The script does not call mutation endpoints. **Read-only is a +client convention, not a server-enforced permission boundary.** Keep the server +on loopback and do not loosen CORS or give unreviewed generated code API access. Testing the generated page ^^^^^^^^^^^^^^^^^^^^^^^^^^ -Before using real data, verify the generated page against its synthetic fixture: - -1. Open the file directly in a browser (``file://``). It should render the - fixture categories and totals without any network requests. -2. Open browser DevTools → Network. Reload. Confirm no requests go outside - ``localhost:5666``. -3. Enable live mode. DevTools should show only requests to ``localhost:5666``. -4. Try an empty time range (start = end). The page should show zero totals, not - an error. +Before using real data: + +1. Open the HTML file directly. Check Coding = 7200 s, Communication = 1800 s, + Uncategorized = 600 s, active = 9600 s, AFK = 600 s, tracked = 10200 s. + Category and app totals both equal active time, not active plus AFK. +2. Open browser DevTools → Network and reload. Loading the document itself is + expected; it must make no HTTP(S), WebSocket, telemetry or model requests, + including when choosing a local JSON file. CSP is defense in depth, not a + substitute for reviewing the page. +3. Load a summary with empty category/app arrays and zero durations. Expect + zero totals and a clear no-activity state. Try invalid JSON and a reversed + range: expect a visible error, not a silent stale-data success. +4. Test the exporter on synthetic window/AFK events before trusting changed + aggregation code. Check events straddling range boundaries, equivalent times + with different UTC offsets, unknown AFK gaps, and two overlapping devices. + +The reproducible :download:`verification script ` starts +an isolated in-memory Python ``aw-server`` on a random loopback port; it never +connects to your normal server. In a disposable Python environment with +``aw-client``, ``aw-server`` and ``playwright`` installed, place it beside the +exporter and HTML file, install Playwright Chromium, then run +``python3 verify-ai-dashboard.py``. The reference was checked on Python 3.12 with +aw-client 0.5.15, aw-core 0.5.17 and aw-server 0.13.2 source (with Flask 2.2); +this is not a compatibility claim for all installed versions. It checks the known fixture, canonical query +results, range boundaries and browser file loading, and records attempted +network destinations. It does not prove that arbitrary future generated code or +every released server implementation behaves identically. Using the page as a custom visualization ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -ActivityWatch's web UI can embed custom pages as an iframe tab. This feature is -**experimental**. To use it: +This is **experimental and not required for the offline recipe above**. There +is no automatic ``/custom_vis/`` discovery path. The Python server +can explicitly serve a directory (containing ``index.html``), for example: -1. Place the generated HTML file in ``/custom_vis//``. -2. Open ActivityWatch at ``http://localhost:5666``. -3. Navigate to the custom visualizations tab and select ````. +.. code-block:: sh -The iframe receives ``hostname``, ``start``, and ``end`` as URL parameters so it -automatically follows the selected time range. + aw-server --host 127.0.0.1 --port 5666 \ + --custom-static dashboard=/absolute/path/to/reviewed-page + +Use this when configuring your server, not while another server already owns +that port. The page is then at ``http://127.0.0.1:5666/pages/dashboard/``. +The equivalent persistent Python-server setting is a ``dashboard`` path entry +under ``[server.custom_static]`` in ``aw-server.toml``. Check the installed +server's help/config; do not assume Python CLI flags apply to the Rust server. +The Python-server path is covered by the verification script. + +The web UI's custom visualization selector can embed configured pages and passes +``hostname``, ``start`` and ``end`` URL parameters. The offline reference viewer +**does not consume those parameters or automatically follow the selected range**; +its displayed range is the loaded summary. Building a live embedded viewer would +require separate query and security review, not just copying this HTML. .. warning:: diff --git a/src/examples/ai-dashboard-summary.py b/src/examples/ai-dashboard-summary.py new file mode 100644 index 0000000..2477d4b --- /dev/null +++ b/src/examples/ai-dashboard-summary.py @@ -0,0 +1,121 @@ +"""Export one desktop host's bounded, canonical activity as a local summary. + +Requires aw-client. Never sends events to a model or writes to the AW API. +""" + +import argparse +import json +from datetime import datetime, timedelta + +from aw_client import ActivityWatchClient +from aw_client.classes import default_classes +from aw_client.queries import DesktopQueryParams, canonicalEvents + + +def summarize(client, hostname, start, end): + if start.utcoffset() is None or end.utcoffset() is None: + raise ValueError("Use ISO timestamps with explicit timezone offsets") + if end < start: + raise ValueError("End must not precede start") + summary = { + "hostname": hostname, + "start": start.isoformat(), + "end": end.isoformat(), + "categories": [], + "apps": [], + "active_seconds": 0, + "afk_seconds": 0, + "tracked_seconds": 0, + "unknown_seconds": 0, + } + if start == end: + return summary + buckets = client.get_buckets() + window = f"aw-watcher-window_{hostname}" + afk = f"aw-watcher-afk_{hostname}" + if window not in buckets or afk not in buckets: + raise ValueError("Select one host with both window and AFK buckets") + try: + settings = client.get_setting("classes") + except Exception as exc: + # Only absent settings are expected; connection and other errors must surface. + if getattr(getattr(exc, "response", None), "status_code", None) != 404: + raise + settings = None + classes = ( + [(c["name"], c["rule"]) for c in settings] if settings else default_classes + ) + # Explicit classes avoid aw-client's implicit lookup on the default server. + query = canonicalEvents( + DesktopQueryParams(bid_window=window, bid_afk=afk, classes=classes) + ) + # canonicalEvents uses substring-based find_bucket(); bind the exact IDs + # already verified above so similarly named synced hosts cannot win. + for bucket in (window, afk): + query = query.replace(f'find_bucket("{bucket}")', json.dumps(bucket)) + query += "\nRETURN = events;" + active = client.query(query, [(start, end)])[0] + # These queries do not mutate data. POST /query/ is a read computation, + # not an HTTP-method-based permission boundary. + raw_query = f"windows = query_bucket({json.dumps(window)});" + afk_query = ( + raw_query + + f" afk = query_bucket({json.dumps(afk)});" + + ' afk = filter_keyvals(afk, "status", ["afk"]);' + + " RETURN = filter_period_intersect(windows, afk);" + ) + raw = client.query(raw_query + " RETURN = windows;", [(start, end)])[0] + away = client.query(afk_query, [(start, end)])[0] + + # Bucket queries return intersecting events, not necessarily clipped events. + # Canonical queries also flood intervals; clip before aggregating durations. + def clipped(events): + result = [] + for event in events: + timestamp = datetime.fromisoformat( + event["timestamp"].replace("Z", "+00:00") + ) + stop = timestamp + timedelta(seconds=event["duration"]) + seconds = max(0, (min(stop, end) - max(timestamp, start)).total_seconds()) + if seconds: + result.append({**event, "duration": seconds}) + return result + + active, raw, away = clipped(active), clipped(raw), clipped(away) + categories = {} + apps = {} + for event in active: + seconds = event["duration"] + category = tuple(event["data"].get("$category", ["Uncategorized"])) + app = event["data"].get("app", "unknown") + categories[category] = categories.get(category, 0) + seconds + apps[app] = apps.get(app, 0) + seconds + summary.update( + categories=[ + {"name": list(k), "seconds": v} for k, v in sorted(categories.items()) + ], + apps=[{"app": k, "seconds": v} for k, v in sorted(apps.items())], + active_seconds=sum(e["duration"] for e in active), + afk_seconds=sum(e["duration"] for e in away), + tracked_seconds=sum(e["duration"] for e in raw), + ) + summary["unknown_seconds"] = max( + 0, + summary["tracked_seconds"] - summary["active_seconds"] - summary["afk_seconds"], + ) + return summary + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("hostname") + parser.add_argument("start", type=datetime.fromisoformat) + parser.add_argument("end", type=datetime.fromisoformat) + parser.add_argument("--port", type=int, default=5666) + args = parser.parse_args() + client = ActivityWatchClient("local-dashboard", host="127.0.0.1", port=args.port) + print(json.dumps(summarize(client, args.hostname, args.start, args.end), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/src/examples/ai-dashboard.html b/src/examples/ai-dashboard.html new file mode 100644 index 0000000..86e1ab0 --- /dev/null +++ b/src/examples/ai-dashboard.html @@ -0,0 +1,316 @@ + + + + + + +ActivityWatch summary dashboard + + + +
+
+

ActivityWatch summary

+

Synthetic sample data

+

Host: my-machine (one explicit host; not a combined view across devices)

+

Range: 2024-01-15T08:00:00+00:00 → 2024-01-15T12:00:00+00:00

+

Timezone offsets: start +00:00, end +00:00

+
+ +
+ + +
+ + + +
+
+
+ Active + 9600 + seconds + 2h 40m 00s +
+
+ AFK + 600 + seconds + 10m 00s +
+
+ Tracked + 10200 + seconds + 2h 50m 00s +
+
+
+ +
+
+

Categories

+
+
+
+

Apps

+
+
+
+ +
+

About this data

+
    +
  • The summary is read locally in this browser. Nothing is uploaded, stored, or downloaded.
  • +
  • It covers one explicit host. It does not combine data from multiple devices.
  • +
  • Categories are rule-based groupings, not AI inference.
  • +
  • This page is generated code. Review it before relying on it.
  • +
+
+
+ + + + diff --git a/src/examples/verify-ai-dashboard.py b/src/examples/verify-ai-dashboard.py new file mode 100644 index 0000000..8cdb5a5 --- /dev/null +++ b/src/examples/verify-ai-dashboard.py @@ -0,0 +1,283 @@ +"""Synthetic-only integration check. Requires aw-server, aw-client, playwright. + +Run beside ai-dashboard-summary.py and ai-dashboard.html after installing +Playwright Chromium. No existing ActivityWatch instance is used. +""" + +import importlib.util +import json +import os +import tempfile +import threading +from datetime import datetime, timedelta, timezone +from pathlib import Path +from urllib.parse import urlsplit + +from aw_client import ActivityWatchClient +from aw_core.models import Event +from aw_server.server import AWFlask +from flask import request +from playwright.sync_api import sync_playwright +from werkzeug.serving import make_server + +ROOT = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location( + "summary", ROOT / "ai-dashboard-summary.py" +) +summary_module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(summary_module) +summarize = summary_module.summarize + + +def verify(client): + t = datetime(2024, 1, 15, 8, tzinfo=timezone.utc) + # Insert the longer prefix match first to catch substring bucket discovery. + for host in ["fixture-a-other", "fixture-a", "fixture-b"]: + for watcher, kind in [("window", "currentwindow"), ("afk", "afkstatus")]: + client.create_bucket(f"aw-watcher-{watcher}_{host}", event_type=kind) + client.insert_events( + f"aw-watcher-window_{host}", + [ + Event( + timestamp=t, + duration=timedelta(hours=1), + data={"app": "Code", "title": "synthetic"}, + ), + Event( + timestamp=t + timedelta(hours=1), + duration=timedelta(hours=1), + data={"app": "Firefox", "title": "synthetic"}, + ), + ], + ) + client.insert_events( + f"aw-watcher-afk_{host}", + [ + Event( + timestamp=t, + duration=timedelta(minutes=30), + data={"status": "not-afk"}, + ), + Event( + timestamp=t + timedelta(minutes=30), + duration=timedelta(minutes=30), + data={"status": "afk"}, + ), + Event( + timestamp=t + timedelta(hours=1), + duration=timedelta(hours=1), + data={"status": "not-afk"}, + ), + ], + ) + # The similarly named host has different AFK coverage and must not be chosen. + client.delete_bucket("aw-watcher-afk_fixture-a-other") + client.create_bucket("aw-watcher-afk_fixture-a-other", event_type="afkstatus") + client.insert_events( + "aw-watcher-afk_fixture-a-other", + [Event(timestamp=t, duration=timedelta(hours=2), data={"status": "afk"})], + ) + client.set_setting( + "classes", + [ + {"name": ["Coding"], "rule": {"type": "regex", "regex": "Code"}}, + {"name": ["Communication"], "rule": {"type": "regex", "regex": "Firefox"}}, + ], + ) + a = summarize(client, "fixture-a", t, t + timedelta(hours=2)) + assert (a["active_seconds"], a["afk_seconds"], a["tracked_seconds"]) == ( + 5400, + 1800, + 7200, + ), a + assert a["categories"] == [ + {"name": ["Coding"], "seconds": 1800}, + {"name": ["Communication"], "seconds": 3600}, + ], a + assert a["apps"] == [ + {"app": "Code", "seconds": 1800}, + {"app": "Firefox", "seconds": 3600}, + ], a + # Both devices overlap completely; selecting A must not include B. + assert ( + summarize(client, "fixture-b", t, t + timedelta(hours=2))["active_seconds"] + == 5400 + ) + offset = timezone(timedelta(hours=2)) + assert ( + summarize( + client, + "fixture-a", + t.astimezone(offset), + (t + timedelta(hours=2)).astimezone(offset), + )["active_seconds"] + == 5400 + ) + clipped = summarize( + client, "fixture-a", t + timedelta(minutes=15), t + timedelta(minutes=75) + ) + assert ( + clipped["active_seconds"], + clipped["afk_seconds"], + clipped["tracked_seconds"], + ) == (1800, 1800, 3600), clipped + for start, end in [(t, t), (t + timedelta(days=1), t + timedelta(days=1, hours=1))]: + empty = summarize(client, "fixture-a", start, end) + assert ( + empty["active_seconds"] + == empty["afk_seconds"] + == empty["tracked_seconds"] + == 0 + ), empty + for host, start, end in [ + ("fixture-a", t.replace(tzinfo=None), t), + ("fixture-a", t + timedelta(hours=1), t), + ("missing", t, t + timedelta(hours=1)), + ]: + try: + summarize(client, host, start, end) + except ValueError: + pass + else: + raise AssertionError("Invalid host/range accepted") + # A window interval without any AFK coverage remains unknown. + gap = t + timedelta(days=2) + client.insert_events( + "aw-watcher-window_fixture-a", + [Event(timestamp=gap, duration=timedelta(minutes=10), data={"app": "Code"})], + ) + unknown = summarize(client, "fixture-a", gap, gap + timedelta(minutes=10)) + assert ( + unknown["active_seconds"], + unknown["afk_seconds"], + unknown["unknown_seconds"], + ) == (0, 0, 600), unknown + return a, empty + + +def verify_browser(summary, empty, base_url): + destinations, errors = [], [] + with sync_playwright() as p, tempfile.TemporaryDirectory() as tmp: + browser = p.chromium.launch() + context = browser.new_context() + page = context.new_page() + page.on("request", lambda req: destinations.append(req.url)) + page.on("pageerror", lambda err: errors.append(str(err))) + page.on("websocket", lambda ws: destinations.append(ws.url)) + try: + page.goto((ROOT / "ai-dashboard.html").as_uri()) + assert page.locator("#active-seconds").inner_text() == "9600" + assert page.locator("#afk-seconds").inner_text() == "600" + assert page.locator("#tracked-seconds").inner_text() == "10200" + for key, value in [ + ("Coding", "7200"), + ("Communication", "1800"), + ("Uncategorized", "600"), + ]: + row = page.locator("#categories .row").filter(has_text=key) + assert value in row.inner_text() + + def load(data): + path = Path(tmp) / "summary.json" + path.write_text(json.dumps(data)) + page.locator("#summary-file").set_input_files([]) + page.locator("#summary-file").set_input_files(str(path)) + page.wait_for_timeout(100) + + load({**summary, "active_seconds": 3599.6}) + assert page.locator("#active-fmt").inner_text() == "1h 00m 00s" + load(summary) + assert page.locator("#active-seconds").inner_text() == "5400" + assert page.locator("#afk-seconds").inner_text() == "1800" + assert "+00:00" in page.locator("#range").inner_text() + load(empty) + assert page.locator("#active-seconds").inner_text() == "0" + assert "No activity" in page.locator("#categories").inner_text() + load({**summary, "active_seconds": -1}) + assert page.locator("#error").is_visible() + load({**summary, "end": "2024-01-01T00:00:00Z"}) + assert page.locator("#error").is_visible() + bad = Path(tmp) / "bad.json" + bad.write_text("{invalid") + page.locator("#summary-file").set_input_files(str(bad)) + page.wait_for_timeout(100) + assert page.locator("#error").is_visible() + load( + { + **summary, + "hostname": "", + } + ) + assert page.locator("#host").inner_text().startswith("