@chronicle.app/timing-app 0.3.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Louis
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.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # @chronicle.app/timing-app
2
+
3
+ Chronicle plugin that extracts app/screen usage, manually-logged time entries,
4
+ and relayed phone/FaceTime call history from
5
+ [Timing.app](https://timingapp.com)'s local SQLite database.
6
+
7
+ ## Source
8
+
9
+ `~/Library/Application Support/info.eurocomp.Timing2/SQLite.db` (opened read-only).
10
+ Timestamps are Unix epoch seconds.
11
+
12
+ Timing is partly a relay: Mac `AppActivity`/`TaskActivity`/`Project` are native,
13
+ while iOS app usage rides in `AppActivity` via Apple Screen Time, and Apple Call
14
+ History rides in `Event`.
15
+
16
+ ## Extraction types
17
+
18
+ Bare `chronicle extract timing-app` runs the default extractor — a
19
+ reverse-chronological merge of the three focused streams below (so `--limit 1`
20
+ is the single most-recent record across all of them). Each stream is also
21
+ selectable on its own:
22
+
23
+ | `--type` | Table | Emits |
24
+ | ---------------- | ------------------------------ | --------------------------------- |
25
+ | (none, default) | all three, merged newest-first | — |
26
+ | `app-activities` | `AppActivity` | `ExecuteAction` + `DeviceSession` |
27
+ | `time-entries` | `TaskActivity` | `ExperienceAction` + `Session` |
28
+ | `calls` | `Event` (call relay) | `CallAction` + `CallSession` |
29
+
30
+ ## Mapping
31
+
32
+ - **App usage → `ExecuteAction` + `DeviceSession`.** The action carries the
33
+ measured span (`startTime`/`endTime`), `agent: @me`, `object`: the
34
+ `SoftwareApplication`, `instrument`: the `Device`, `result`: the `DeviceSession`.
35
+ The session carries `isPartOf` → Project and, for the on-screen resource, a
36
+ URL-keyed web `Entity` or a realm-scoped filesystem entry as its `subject`.
37
+ A filesystem entry is keyed by path within the machine's name-keyed `:Realm`
38
+ (`source: hostname`, via `inRealm`) — not globally, since a path isn't unique.
39
+ A trailing-slash path is a `:Directory` and lands on `workingDirectory` (the
40
+ terminal CWD / Finder folder the block happened in); any other path IS the
41
+ media object living there, typed by extension as far as a path-only source can
42
+ tell (`ImageObject`/`AudioObject`/`VideoObject`/`DocumentObject`, else a plain
43
+ `MediaObject` a byte-reader may refine later). The window title, when it adds
44
+ anything beyond the subject's name, holds the session's `name`.
45
+ - **Time entry → `ExperienceAction` + `Session`.** A manually-logged block of
46
+ real time (Timing's "time entries"; each belongs to a project). The action
47
+ carries the real occurrence (`startTime`/`endTime`, not `scheduledTime`); the
48
+ lean `Session` object carries `isPartOf` → Project, `name` (title), and `description` (notes).
49
+ - **Project ancestry** is nested via `isPartOf` on the session (leaf → parent →
50
+ …), not emitted as standalone records.
51
+ - **Call relay (`--type calls`).** Timing relays Apple Call History; this plugin
52
+ reads that DB. It emits `CallAction`/`CallSession` under `source: apple-call-history`
53
+ - the shared call UUID, so it folds with the Apple Call History plugin's richer
54
+ Apple records — supplying the long tail Apple prunes. Direction is lost and
55
+ named contacts carry no number, so these are thin (a name, no participant).
56
+
57
+ ## Identity
58
+
59
+ - App: bundle id (`source: apple-bundle-id`) where present, else `timing-app` +
60
+ executable/title. Never keyed on the local `Application.id`.
61
+ - Device: `timing-app` + `Device.globalID`, with the hardware MAC linked via
62
+ `sameAs` (`source: mac-address`) on Macs.
63
+ - `@me`: Timing has no account of its own, so the self node is just the `@me` tag
64
+ sourced to `timing-app` (no assumed name); the token merges it with the
65
+ subject's named identities from other sources.
66
+
67
+ ## Usage
68
+
69
+ ```bash
70
+ # Default merge, recent window (~90 days), newest-first
71
+ chronicle extract timing-app
72
+
73
+ # Full history
74
+ chronicle extract timing-app --all --limit 0
75
+
76
+ # A single focused stream
77
+ chronicle extract timing-app --type app-activities
78
+ chronicle extract timing-app --type time-entries
79
+ chronicle extract timing-app --type calls
80
+
81
+ # Another copy of the database
82
+ chronicle extract timing-app --input /path/to/SQLite.db
83
+ ```
84
+
85
+ Tests use a synthetic Timing `SQLite.db` and a synthetic AddressBook under a
86
+ temporary `HOME`, so they never read the host's Timing data or contacts.
@@ -0,0 +1,20 @@
1
+ import { Record } from '@chronicle.app/etl';
2
+ import TimingAppTransformer from './TimingAppTransformer.js';
3
+ import { TimingDbExtractor } from './TimingDbExtractor.js';
4
+ /**
5
+ * Automatic app/screen usage from Timing's `AppActivity` table (6M+ rows),
6
+ * streamed newest-first with a cursor. Each row → an `ExecuteAction` with a
7
+ * `DeviceSession` result.
8
+ */
9
+ export declare class AppUsageExtractor extends TimingDbExtractor<typeof AppUsageExtractor> {
10
+ static source: string;
11
+ static description: string;
12
+ static recordTypes: string[];
13
+ static default: boolean;
14
+ static defaultTransformer: typeof TimingAppTransformer;
15
+ extract(): AsyncGenerator<Record>;
16
+ private rows;
17
+ private buildRecord;
18
+ determineCount(): Promise<number | null>;
19
+ }
20
+ //# sourceMappingURL=AppUsageExtractor.d.ts.map
@@ -0,0 +1,86 @@
1
+ import { iterateRows } from '@chronicle.app/etl-sqlite';
2
+ import TimingAppTransformer from './TimingAppTransformer.js';
3
+ import { TimingDbExtractor } from './TimingDbExtractor.js';
4
+ /**
5
+ * Automatic app/screen usage from Timing's `AppActivity` table (6M+ rows),
6
+ * streamed newest-first with a cursor. Each row → an `ExecuteAction` with a
7
+ * `DeviceSession` result.
8
+ */
9
+ export class AppUsageExtractor extends TimingDbExtractor {
10
+ static source = 'timing-app';
11
+ static description = 'Automatic app and screen usage';
12
+ static recordTypes = ['app-activities'];
13
+ static default = false;
14
+ static defaultTransformer = TimingAppTransformer;
15
+ async *extract() {
16
+ if (!this.db)
17
+ throw new Error('Database not initialized');
18
+ const projectChain = this.projectChainResolver();
19
+ const devices = this.loadDevices();
20
+ for (const row of this.rows()) {
21
+ yield this.buildRecord(row, devices, projectChain);
22
+ }
23
+ }
24
+ rows() {
25
+ const since = this.sinceSeconds();
26
+ const until = this.untilSeconds();
27
+ const conditions = ['a.isDeleted = 0'];
28
+ const params = [];
29
+ if (since !== null) {
30
+ conditions.push('a.startDate > ?');
31
+ params.push(since);
32
+ }
33
+ if (until !== null) {
34
+ conditions.push('a.startDate < ?');
35
+ params.push(until);
36
+ }
37
+ const limit = this.getEffectiveLimit();
38
+ let sql = `
39
+ SELECT CAST(a.id AS TEXT) AS id, a.startDate, a.endDate,
40
+ CAST(a.localDeviceID AS TEXT) AS deviceLocalId,
41
+ app.bundleIdentifier AS bundleIdentifier, app.executable AS executable,
42
+ app.title AS appTitle,
43
+ CAST(a.projectID AS TEXT) AS projectId,
44
+ t.stringValue AS windowTitle, p.stringValue AS pathString
45
+ FROM AppActivity a
46
+ LEFT JOIN Application app ON a.applicationID = app.id
47
+ LEFT JOIN Title t ON a.titleID = t.id
48
+ LEFT JOIN Path p ON a.pathID = p.id
49
+ WHERE ${conditions.join(' AND ')}
50
+ ORDER BY a.startDate DESC`;
51
+ if (limit !== null) {
52
+ sql += ` LIMIT ?`;
53
+ params.push(limit);
54
+ }
55
+ return iterateRows(this.db.prepare(sql), ...params);
56
+ }
57
+ buildRecord(row, devices, projectChain) {
58
+ return this.createRecord({}, {
59
+ recordType: 'app-activities',
60
+ id: row.id,
61
+ startDate: row.startDate,
62
+ endDate: row.endDate,
63
+ app: {
64
+ bundleIdentifier: row.bundleIdentifier || null,
65
+ executable: row.executable || null,
66
+ title: row.appTitle || null,
67
+ },
68
+ device: devices.get(row.deviceLocalId) ?? null,
69
+ projectChain: projectChain(row.projectId),
70
+ windowTitle: row.windowTitle || null,
71
+ pathString: row.pathString || null,
72
+ });
73
+ }
74
+ async determineCount() {
75
+ if (!this.db)
76
+ return null;
77
+ try {
78
+ const total = this.countWindow('AppActivity', 'startDate');
79
+ const limit = this.getEffectiveLimit();
80
+ return limit === null ? total : Math.min(limit, total);
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,21 @@
1
+ import { Record } from '@chronicle.app/etl';
2
+ import TimingAppTransformer from './TimingAppTransformer.js';
3
+ import { TimingDbExtractor } from './TimingDbExtractor.js';
4
+ /**
5
+ * Manually-logged time entries from Timing's `TaskActivity` table. Timing calls
6
+ * these "time entries" (each belongs to a project). Small and high-signal —
7
+ * deliberately recorded, unlike the automatic app usage. Each → an
8
+ * `ExperienceAction` carrying a lean `Session`.
9
+ */
10
+ export declare class TimeEntriesExtractor extends TimingDbExtractor<typeof TimeEntriesExtractor> {
11
+ static source: string;
12
+ static description: string;
13
+ static recordTypes: string[];
14
+ static default: boolean;
15
+ static defaultTransformer: typeof TimingAppTransformer;
16
+ extract(): AsyncGenerator<Record>;
17
+ private rows;
18
+ private buildRecord;
19
+ determineCount(): Promise<number | null>;
20
+ }
21
+ //# sourceMappingURL=TimeEntriesExtractor.d.ts.map
@@ -0,0 +1,73 @@
1
+ import { iterateRows } from '@chronicle.app/etl-sqlite';
2
+ import TimingAppTransformer from './TimingAppTransformer.js';
3
+ import { TimingDbExtractor } from './TimingDbExtractor.js';
4
+ /**
5
+ * Manually-logged time entries from Timing's `TaskActivity` table. Timing calls
6
+ * these "time entries" (each belongs to a project). Small and high-signal —
7
+ * deliberately recorded, unlike the automatic app usage. Each → an
8
+ * `ExperienceAction` carrying a lean `Session`.
9
+ */
10
+ export class TimeEntriesExtractor extends TimingDbExtractor {
11
+ static source = 'timing-app';
12
+ static description = 'Manually logged time entries';
13
+ static recordTypes = ['time-entries'];
14
+ static default = false;
15
+ static defaultTransformer = TimingAppTransformer;
16
+ async *extract() {
17
+ if (!this.db)
18
+ throw new Error('Database not initialized');
19
+ const projectChain = this.projectChainResolver();
20
+ for (const row of this.rows()) {
21
+ yield this.buildRecord(row, projectChain);
22
+ }
23
+ }
24
+ rows() {
25
+ const since = this.sinceSeconds();
26
+ const until = this.untilSeconds();
27
+ const conditions = ['isDeleted = 0', 'isRunning = 0'];
28
+ const params = [];
29
+ if (since !== null) {
30
+ conditions.push('startDate > ?');
31
+ params.push(since);
32
+ }
33
+ if (until !== null) {
34
+ conditions.push('startDate < ?');
35
+ params.push(until);
36
+ }
37
+ const limit = this.getEffectiveLimit();
38
+ let sql = `
39
+ SELECT CAST(id AS TEXT) AS id, startDate, endDate,
40
+ CAST(projectID AS TEXT) AS projectId, title, notes
41
+ FROM TaskActivity
42
+ WHERE ${conditions.join(' AND ')}
43
+ ORDER BY startDate DESC`;
44
+ if (limit !== null) {
45
+ sql += ` LIMIT ?`;
46
+ params.push(limit);
47
+ }
48
+ return iterateRows(this.db.prepare(sql), ...params);
49
+ }
50
+ buildRecord(row, projectChain) {
51
+ return this.createRecord({}, {
52
+ recordType: 'time-entries',
53
+ id: row.id,
54
+ startDate: row.startDate,
55
+ endDate: row.endDate,
56
+ title: row.title || null,
57
+ notes: row.notes || null,
58
+ projectChain: projectChain(row.projectId),
59
+ });
60
+ }
61
+ async determineCount() {
62
+ if (!this.db)
63
+ return null;
64
+ try {
65
+ const total = this.countWindow('TaskActivity', 'startDate', ' AND isRunning = 0');
66
+ const limit = this.getEffectiveLimit();
67
+ return limit === null ? total : Math.min(limit, total);
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,48 @@
1
+ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
+ import { ActionAndChildren } from '@chronicle.app/schema';
3
+ export default class TimingAppTransformer extends ChronicleTransformer {
4
+ static source: string;
5
+ private meAgent?;
6
+ transform(record: Record): Promise<ActionAndChildren[]>;
7
+ /**
8
+ * The account owner, tagged `@me`. Timing has no account of its own (and it is
9
+ * not an iCloud identity), so we don't assume a name — the node is just the
10
+ * `@me` tag sourced to Timing, keyed on `['@type','source']`. The `@me` token
11
+ * merges it with the subject's named identities from other sources.
12
+ */
13
+ private me;
14
+ /**
15
+ * A Project node with its ancestry nested via isPartOf. `chain` is leaf-first
16
+ * ([self, parent, ...]); returns the leaf node, or null when unattributed.
17
+ *
18
+ * Each node asserts `isPartOf` as the complete parent set as of this read, so
19
+ * a project reparented (or promoted to root) in Timing closes its old parent
20
+ * edge instead of accumulating both.
21
+ */
22
+ private buildProjectChain;
23
+ private buildAppActivity;
24
+ /** The on-screen resource: a URL-keyed web page, or a realm-scoped filesystem entry. */
25
+ private buildResource;
26
+ /**
27
+ * The logical machine as a name-keyed `:Realm` — a file's `inRealm` scope and
28
+ * the device's `inRealm`. Keyed on the normalized name (Timing's device name →
29
+ * `pat-mbp`), so it folds with the same machine seen by the shell plugin, unifies
30
+ * across sources, and survives the hardware migrating.
31
+ */
32
+ private buildRealm;
33
+ /**
34
+ * The physical device — the action's `instrument`. Keyed on Timing's stable
35
+ * device id (with the hardware MAC linked via `sameAs`), so distinct hardware
36
+ * stays distinct: two MAC addresses are two devices, never merged. `inRealm`
37
+ * links it to the logical machine (`realm`) it runs on.
38
+ */
39
+ private buildDevice;
40
+ private buildApplication;
41
+ /**
42
+ * A manually-logged time entry. It's real lived activity (not a plan), so it's
43
+ * an `ExperienceAction` carrying the real occurrence, with the produced `Session`
44
+ * as its `result` — symmetric with app usage (`ExecuteAction` → `DeviceSession`).
45
+ */
46
+ private buildTimeEntry;
47
+ }
48
+ //# sourceMappingURL=TimingAppTransformer.d.ts.map