@chronicle.app/timing-app 0.4.0 → 0.5.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.
@@ -1,4 +1,4 @@
1
- import { Record } from '@chronicle.app/etl';
1
+ import { Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
2
2
  import TimingAppTransformer from './TimingAppTransformer.js';
3
3
  import { TimingDbExtractor } from './TimingDbExtractor.js';
4
4
  /**
@@ -16,5 +16,10 @@ export declare class AppUsageExtractor extends TimingDbExtractor<typeof AppUsage
16
16
  private rows;
17
17
  private buildRecord;
18
18
  determineCount(): Promise<number | null>;
19
+ /**
20
+ * The activities an uncapped run reads, when, and the time they span. The
21
+ * run's joins are left out: each is to a primary key, so it keeps every row.
22
+ */
23
+ survey(options?: SurveyOptions): Promise<Survey | null>;
19
24
  }
20
25
  //# sourceMappingURL=AppUsageExtractor.d.ts.map
@@ -1,6 +1,6 @@
1
- import { iterateRows } from '@chronicle.app/etl-sqlite';
1
+ import { getRow, iterateRows } from '@chronicle.app/etl-sqlite';
2
2
  import TimingAppTransformer from './TimingAppTransformer.js';
3
- import { TimingDbExtractor } from './TimingDbExtractor.js';
3
+ import { TimingDbExtractor, startsByMonth, timeSurvey, } from './TimingDbExtractor.js';
4
4
  /**
5
5
  * Automatic app/screen usage from Timing's `AppActivity` table (6M+ rows),
6
6
  * streamed newest-first with a cursor. Each row → an `ExecuteAction` with a
@@ -22,18 +22,7 @@ export class AppUsageExtractor extends TimingDbExtractor {
22
22
  }
23
23
  }
24
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
- }
25
+ const { conditions, params } = this.windowFilter('a');
37
26
  const limit = this.getEffectiveLimit();
38
27
  let sql = `
39
28
  SELECT CAST(a.id AS TEXT) AS id, a.startDate, a.endDate,
@@ -83,4 +72,21 @@ export class AppUsageExtractor extends TimingDbExtractor {
83
72
  return null;
84
73
  }
85
74
  }
75
+ /**
76
+ * The activities an uncapped run reads, when, and the time they span. The
77
+ * run's joins are left out: each is to a primary key, so it keeps every row.
78
+ */
79
+ async survey(options = {}) {
80
+ if (!this.db)
81
+ return null;
82
+ const { conditions, params } = this.windowFilter('a', this.sinceSeconds(null));
83
+ const rows = `FROM AppActivity a WHERE ${conditions.join(' AND ')}`;
84
+ const row = getRow(this.db.prepare(`SELECT COUNT(*) AS n, MIN(a.startDate) AS first, MAX(a.startDate) AS last,
85
+ COALESCE(SUM(a.endDate - a.startDate), 0) AS seconds
86
+ ${rows}`), ...params);
87
+ const byMonth = options.byMonth
88
+ ? startsByMonth(this.db, 'a.startDate', rows, params)
89
+ : undefined;
90
+ return { 'app-activities': timeSurvey(row, 'activities', byMonth) };
91
+ }
86
92
  }
@@ -1,4 +1,4 @@
1
- import { Record } from '@chronicle.app/etl';
1
+ import { Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
2
2
  import TimingAppTransformer from './TimingAppTransformer.js';
3
3
  import { TimingDbExtractor } from './TimingDbExtractor.js';
4
4
  /**
@@ -17,5 +17,9 @@ export declare class TimeEntriesExtractor extends TimingDbExtractor<typeof TimeE
17
17
  private rows;
18
18
  private buildRecord;
19
19
  determineCount(): Promise<number | null>;
20
+ /** The entries an uncapped run reads, when, and the time they span. */
21
+ survey(options?: SurveyOptions): Promise<Survey | null>;
22
+ /** Finished entries within the window. */
23
+ private entryFilter;
20
24
  }
21
25
  //# sourceMappingURL=TimeEntriesExtractor.d.ts.map
@@ -1,6 +1,6 @@
1
- import { iterateRows } from '@chronicle.app/etl-sqlite';
1
+ import { getRow, iterateRows } from '@chronicle.app/etl-sqlite';
2
2
  import TimingAppTransformer from './TimingAppTransformer.js';
3
- import { TimingDbExtractor } from './TimingDbExtractor.js';
3
+ import { TimingDbExtractor, startsByMonth, timeSurvey, } from './TimingDbExtractor.js';
4
4
  /**
5
5
  * Manually-logged time entries from Timing's `TaskActivity` table. Timing calls
6
6
  * these "time entries" (each belongs to a project). Small and high-signal —
@@ -22,18 +22,7 @@ export class TimeEntriesExtractor extends TimingDbExtractor {
22
22
  }
23
23
  }
24
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
- }
25
+ const { conditions, params } = this.entryFilter();
37
26
  const limit = this.getEffectiveLimit();
38
27
  let sql = `
39
28
  SELECT CAST(id AS TEXT) AS id, startDate, endDate,
@@ -70,4 +59,21 @@ export class TimeEntriesExtractor extends TimingDbExtractor {
70
59
  return null;
71
60
  }
72
61
  }
62
+ /** The entries an uncapped run reads, when, and the time they span. */
63
+ async survey(options = {}) {
64
+ if (!this.db)
65
+ return null;
66
+ const { conditions, params } = this.entryFilter(this.sinceSeconds(null));
67
+ const rows = `FROM TaskActivity WHERE ${conditions.join(' AND ')}`;
68
+ const row = getRow(this.db.prepare(`SELECT COUNT(*) AS n, MIN(startDate) AS first, MAX(startDate) AS last,
69
+ COALESCE(SUM(endDate - startDate), 0) AS seconds
70
+ ${rows}`), ...params);
71
+ const byMonth = options.byMonth ? startsByMonth(this.db, 'startDate', rows, params) : undefined;
72
+ return { 'time-entries': timeSurvey(row, 'entries', byMonth) };
73
+ }
74
+ /** Finished entries within the window. */
75
+ entryFilter(since = this.sinceSeconds()) {
76
+ const { conditions, params } = this.windowFilter('TaskActivity', since);
77
+ return { conditions: [...conditions, 'TaskActivity.isRunning = 0'], params };
78
+ }
73
79
  }
@@ -96,9 +96,9 @@ export default class TimingAppTransformer extends ChronicleTransformer {
96
96
  }
97
97
  const action = {
98
98
  '@type': 'ExecuteAction',
99
- '@key': ['@type', 'source', 'sourceId'],
99
+ // Keyed by the session it made, which has the activity's ID.
100
+ '@key': ['@type', 'source', 'result.sourceId'],
100
101
  source: SOURCE,
101
- sourceId: ctx.id,
102
102
  startTime: secondsToISO(ctx.startDate),
103
103
  endTime: secondsToISO(ctx.endDate),
104
104
  agent: this.me(),
@@ -190,10 +190,7 @@ export default class TimingAppTransformer extends ChronicleTransformer {
190
190
  sourceId: device.globalId,
191
191
  name: device.name,
192
192
  };
193
- // Kind is open-ended data on :category (not a subclass); make/model on :model.
194
- const category = deviceCategory(device.model);
195
- if (category)
196
- node.category = [category];
193
+ // Its make and model, such as `Mac15,3`, says what kind of device it is.
197
194
  if (device.model)
198
195
  node.model = device.model;
199
196
  if (device.mac) {
@@ -254,9 +251,9 @@ export default class TimingAppTransformer extends ChronicleTransformer {
254
251
  session.isPartOf = [project];
255
252
  const action = {
256
253
  '@type': 'ExperienceAction',
257
- '@key': ['@type', 'source', 'sourceId'],
254
+ // Keyed by the session it made, which has the time entry's ID.
255
+ '@key': ['@type', 'source', 'result.sourceId'],
258
256
  source: SOURCE,
259
- sourceId: ctx.id,
260
257
  startTime: secondsToISO(ctx.startDate),
261
258
  endTime: secondsToISO(ctx.endDate),
262
259
  agent: this.me(),
@@ -268,18 +265,6 @@ export default class TimingAppTransformer extends ChronicleTransformer {
268
265
  function secondsToISO(seconds) {
269
266
  return new Date(seconds * 1000).toISOString();
270
267
  }
271
- /** The device kind (open-ended :category data), inferred from Timing's model code. */
272
- function deviceCategory(model) {
273
- if (!model)
274
- return null;
275
- if (/^Mac/i.test(model))
276
- return 'computer';
277
- if (/^iPhone/i.test(model))
278
- return 'phone';
279
- if (/^iPad/i.test(model))
280
- return 'tablet';
281
- return null;
282
- }
283
268
  /** Normalize a Timing Path value to an absolute filesystem path, or null if it isn't one. */
284
269
  function toFilePath(value) {
285
270
  let p = value;
@@ -1,4 +1,4 @@
1
- import { Record } from '@chronicle.app/etl';
1
+ import { Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
2
2
  import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
3
3
  import TimingCallTransformer from './TimingCallTransformer.js';
4
4
  /**
@@ -21,5 +21,9 @@ export declare class TimingCallExtractor extends SqliteExtractor<typeof TimingCa
21
21
  static temporality: "event";
22
22
  static schema: any;
23
23
  extract(): AsyncGenerator<Record>;
24
+ /** The calls a run reads, when, and the time they span; a call with no end spans none. */
25
+ survey(options?: SurveyOptions): Promise<Survey | null>;
26
+ /** Call History events, not deleted, that start after since and before until. */
27
+ private callFilter;
24
28
  }
25
29
  //# sourceMappingURL=TimingCallExtractor.d.ts.map
@@ -1,7 +1,8 @@
1
1
  import { normalizePhoneNumber } from '@chronicle.app/etl';
2
- import { SqliteExtractor, iterateRows } from '@chronicle.app/etl-sqlite';
2
+ import { SqliteExtractor, getRow, iterateRows } from '@chronicle.app/etl-sqlite';
3
3
  import { z } from 'zod';
4
4
  import TimingCallTransformer from './TimingCallTransformer.js';
5
+ import { startsByMonth, timeSurvey } from './TimingDbExtractor.js';
5
6
  const DEFAULT_DB = `${process.env.HOME}/Library/Application Support/info.eurocomp.Timing2/SQLite.db`;
6
7
  /**
7
8
  * Timing.app's relay of Apple Call History — the historical supplement for the
@@ -27,20 +28,7 @@ export class TimingCallExtractor extends SqliteExtractor {
27
28
  async *extract() {
28
29
  if (!this.db)
29
30
  throw new Error('Database not initialized');
30
- const conditions = [
31
- "i.type = 'com.apple.CallHistory'",
32
- 'e.deleted_at IS NULL',
33
- 'e.origin_id IS NOT NULL',
34
- ];
35
- const params = [];
36
- if (this.config.since) {
37
- conditions.push('e.start_date > ?');
38
- params.push(this.config.since.getTime() / 1000);
39
- }
40
- if (this.config.until) {
41
- conditions.push('e.start_date < ?');
42
- params.push(this.config.until.getTime() / 1000);
43
- }
31
+ const { conditions, params } = this.callFilter();
44
32
  const limit = this.getEffectiveLimit();
45
33
  let sql = `
46
34
  SELECT e.origin_id AS uuid, e.start_date AS startSec, e.end_date AS endSec,
@@ -75,4 +63,39 @@ export class TimingCallExtractor extends SqliteExtractor {
75
63
  });
76
64
  }
77
65
  }
66
+ /** The calls a run reads, when, and the time they span; a call with no end spans none. */
67
+ async survey(options = {}) {
68
+ if (!this.db)
69
+ return null;
70
+ const { conditions, params } = this.callFilter();
71
+ const rows = `FROM Event e
72
+ JOIN Integration i ON e.integration_id = i.id
73
+ LEFT JOIN EventSource es ON e.event_source_id = es.id
74
+ WHERE ${conditions.join(' AND ')}`;
75
+ const row = getRow(this.db.prepare(`SELECT COUNT(*) AS n, MIN(e.start_date) AS first, MAX(e.start_date) AS last,
76
+ COALESCE(SUM(e.end_date - e.start_date), 0) AS seconds
77
+ ${rows}`), ...params);
78
+ const byMonth = options.byMonth
79
+ ? startsByMonth(this.db, 'e.start_date', rows, params)
80
+ : undefined;
81
+ return { calls: timeSurvey(row, 'calls', byMonth) };
82
+ }
83
+ /** Call History events, not deleted, that start after since and before until. */
84
+ callFilter() {
85
+ const conditions = [
86
+ "i.type = 'com.apple.CallHistory'",
87
+ 'e.deleted_at IS NULL',
88
+ 'e.origin_id IS NOT NULL',
89
+ ];
90
+ const params = [];
91
+ if (this.config.since) {
92
+ conditions.push('e.start_date > ?');
93
+ params.push(this.config.since.getTime() / 1000);
94
+ }
95
+ if (this.config.until) {
96
+ conditions.push('e.start_date < ?');
97
+ params.push(this.config.until.getTime() / 1000);
98
+ }
99
+ return { conditions, params };
100
+ }
78
101
  }
@@ -1,5 +1,5 @@
1
1
  import { ChronicleTransformer, normalizePhoneNumber } from '@chronicle.app/etl';
2
- import { lookupContact, lookupContactByName } from '@chronicle.app/icloud';
2
+ import { lookupContact, lookupContactByName } from '@chronicle.app/apple';
3
3
  // The call UUID is Apple's (relayed through Timing's Event.origin_id), so calls
4
4
  // key on `apple-call-history` + UUID and fold with the Apple Call History plugin's calls.
5
5
  const SOURCE = 'apple-call-history';
@@ -61,9 +61,10 @@ export default class TimingCallTransformer extends ChronicleTransformer {
61
61
  }
62
62
  return {
63
63
  '@type': 'CallAction',
64
- '@key': ['@type', 'source', 'sourceId'],
64
+ // Keyed by its session, as the apple-call-history plugin keys a call, so
65
+ // the two fold into one call.
66
+ '@key': ['@type', 'source', 'result.sourceId'],
65
67
  source: SOURCE,
66
- sourceId: ctx.uuid,
67
68
  startTime: secondsToISO(ctx.startSec),
68
69
  endTime: secondsToISO(ctx.endSec ?? ctx.startSec),
69
70
  result: session,
@@ -1,4 +1,6 @@
1
+ import type { KindSurvey } from '@chronicle.app/etl';
1
2
  import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
3
+ import type { DatabaseSync, SQLInputValue } from 'node:sqlite';
2
4
  export declare const DEFAULT_DB: string;
3
5
  export declare const DEFAULT_WINDOW_DAYS = 90;
4
6
  export interface TimingDevice {
@@ -27,14 +29,39 @@ export declare abstract class TimingDbExtractor<SelfClass extends typeof SqliteE
27
29
  * none. An unbounded `--limit 0` also drops the window — asking for "no cap"
28
30
  * is a request for a full backfill, not the newest 90 days without a count cap.
29
31
  */
30
- protected sinceSeconds(): number | null;
32
+ protected sinceSeconds(limit?: number | null): number | null;
31
33
  protected untilSeconds(): number | null;
34
+ /** Rows of `alias` not deleted that start after `since` and before `until`. */
35
+ protected windowFilter(alias: string, since?: number | null): {
36
+ conditions: string[];
37
+ params: number[];
38
+ };
32
39
  protected loadDevices(): Map<string, TimingDevice>;
33
40
  protected loadProjects(): Map<string, any>;
34
41
  /** A resolver that nests a project's full ancestry (leaf-first) for a row. */
35
42
  protected projectChainResolver(): (id: string | null) => TimingProject[];
36
43
  protected countWindow(table: string, col: string, extra?: string): number;
37
44
  }
45
+ /**
46
+ * One aggregate over a kind's rows: how many, their first and last start in
47
+ * epoch seconds, and the seconds they span.
48
+ */
49
+ export interface SpanRow {
50
+ n: number;
51
+ first: number | null;
52
+ last: number | null;
53
+ seconds: number;
54
+ }
55
+ /** A kind's survey from its aggregate, with its months when asked. */
56
+ export declare function timeSurvey(row: SpanRow, label: string, byMonth?: KindSurvey['byMonth']): KindSurvey;
57
+ /**
58
+ * How many of a kind's rows start in each month, over the FROM and WHERE of
59
+ * its aggregate.
60
+ * @param start A trusted SQL expression for the start, in epoch seconds
61
+ */
62
+ export declare function startsByMonth(db: DatabaseSync, start: string, rows: string, params: SQLInputValue[]): {
63
+ [month: string]: number;
64
+ };
38
65
  /** A project's ancestry, leaf-first: [self, parent, grandparent, ...]. */
39
66
  export declare function resolveChain(projects: Map<string, any>, id: string | null): TimingProject[];
40
67
  //# sourceMappingURL=TimingDbExtractor.d.ts.map
@@ -1,4 +1,4 @@
1
- import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
1
+ import { SqliteExtractor, allRows, countsByMonth, monthSql } from '@chronicle.app/etl-sqlite';
2
2
  import { z } from 'zod';
3
3
  export const DEFAULT_DB = `${process.env.HOME}/Library/Application Support/info.eurocomp.Timing2/SQLite.db`;
4
4
  export const DEFAULT_WINDOW_DAYS = 90;
@@ -23,16 +23,31 @@ export class TimingDbExtractor extends SqliteExtractor {
23
23
  * none. An unbounded `--limit 0` also drops the window — asking for "no cap"
24
24
  * is a request for a full backfill, not the newest 90 days without a count cap.
25
25
  */
26
- sinceSeconds() {
26
+ sinceSeconds(limit = this.getEffectiveLimit()) {
27
27
  if (this.config.since)
28
28
  return this.config.since.getTime() / 1000;
29
- if (this.config.all || this.getEffectiveLimit() === null)
29
+ if (this.config.all || limit === null)
30
30
  return null;
31
31
  return (Date.now() - DEFAULT_WINDOW_DAYS * 86_400_000) / 1000;
32
32
  }
33
33
  untilSeconds() {
34
34
  return this.config.until ? this.config.until.getTime() / 1000 : null;
35
35
  }
36
+ /** Rows of `alias` not deleted that start after `since` and before `until`. */
37
+ windowFilter(alias, since = this.sinceSeconds()) {
38
+ const until = this.untilSeconds();
39
+ const conditions = [`${alias}.isDeleted = 0`];
40
+ const params = [];
41
+ if (since !== null) {
42
+ conditions.push(`${alias}.startDate > ?`);
43
+ params.push(since);
44
+ }
45
+ if (until !== null) {
46
+ conditions.push(`${alias}.startDate < ?`);
47
+ params.push(until);
48
+ }
49
+ return { conditions, params };
50
+ }
36
51
  loadDevices() {
37
52
  const rows = this.db.prepare(`SELECT CAST(localID AS TEXT) AS localId, CAST(globalID AS TEXT) AS globalId,
38
53
  macAddress, displayName, property_bag AS propertyBag
@@ -93,6 +108,24 @@ export class TimingDbExtractor extends SqliteExtractor {
93
108
  return this.db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${where}`).get(...params).n;
94
109
  }
95
110
  }
111
+ /** A kind's survey from its aggregate, with its months when asked. */
112
+ export function timeSurvey(row, label, byMonth) {
113
+ return {
114
+ count: row.n,
115
+ ...(row.first !== null && { earliest: new Date(row.first * 1000) }),
116
+ ...(row.last !== null && { latest: new Date(row.last * 1000) }),
117
+ ...(byMonth && { byMonth }),
118
+ measures: [{ key: 'duration', label, value: row.seconds, unit: 'seconds' }],
119
+ };
120
+ }
121
+ /**
122
+ * How many of a kind's rows start in each month, over the FROM and WHERE of
123
+ * its aggregate.
124
+ * @param start A trusted SQL expression for the start, in epoch seconds
125
+ */
126
+ export function startsByMonth(db, start, rows, params) {
127
+ return countsByMonth(allRows(db.prepare(`SELECT ${monthSql(start)} AS month, COUNT(*) AS n ${rows} GROUP BY month`), ...params));
128
+ }
96
129
  /** A project's ancestry, leaf-first: [self, parent, grandparent, ...]. */
97
130
  export function resolveChain(projects, id) {
98
131
  const chain = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chronicle.app/timing-app",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "files": [
@@ -24,9 +24,9 @@
24
24
  "type": "module",
25
25
  "description": "Chronicle plugin for extracting app usage, projects, and time blocks from Timing.app",
26
26
  "devDependencies": {
27
- "@chronicle.app/etl": "0.4.0",
28
- "@chronicle.app/schema": "0.4.0",
29
- "@chronicle.app/tsconfig": "0.4.0",
27
+ "@chronicle.app/etl": "0.5.0",
28
+ "@chronicle.app/schema": "0.5.0",
29
+ "@chronicle.app/tsconfig": "0.5.0",
30
30
  "@types/node": "^22.10.1",
31
31
  "typescript": "^5.7.2"
32
32
  },
@@ -41,8 +41,8 @@
41
41
  "registry": "https://registry.npmjs.org/"
42
42
  },
43
43
  "dependencies": {
44
- "@chronicle.app/etl-sqlite": "0.4.0",
45
- "@chronicle.app/icloud": "0.4.0",
44
+ "@chronicle.app/etl-sqlite": "0.5.0",
45
+ "@chronicle.app/apple": "0.5.0",
46
46
  "zod": "^3.24.1"
47
47
  },
48
48
  "peerDependencies": {
@@ -70,7 +70,12 @@
70
70
  "time-entries",
71
71
  "calls"
72
72
  ],
73
- "default": true
73
+ "default": true,
74
+ "locations": [
75
+ {
76
+ "path": "~/Library/Application Support/info.eurocomp.Timing2/SQLite.db"
77
+ }
78
+ ]
74
79
  }
75
80
  },
76
81
  "platforms": [