@chronicle.app/foodnoms 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.
package/README.md CHANGED
@@ -10,8 +10,8 @@ Records are food entries, newest first, joined to their meal slot. `since`/`unti
10
10
 
11
11
  Each action happens at the entry's `timestamp` and is keyed by the entry's ID without its type. FoodNoms has no food-or-drink flag, so an entry whose name contains a common drink word becomes a `DrinkAction`, and anything else an `EatAction`. Keeping the type out of the key lets that guess change without changing the action's identity.
12
12
 
13
- The object is a `Meal` keyed by the same entry ID and named after the food. Its `category` is the meal slot: the built-in slots map to Breakfast, Lunch, Dinner and Snacks, a customized slot uses its own name, and an unnamed custom slot has none. The `description` joins the slot, brand, amount and calories.
13
+ The object is a `Meal`, named after the food and keyed by the action it was eaten in (`action.sourceId`), so the entry's ID is the action's alone. Its `category` is the meal slot: the built-in slots map to Breakfast, Lunch, Dinner and Snacks, a customized slot uses its own name, and an unnamed custom slot has none. The `description` joins the slot, brand, amount and calories.
14
14
 
15
- The agent is the iCloud account owner from `@chronicle.app/icloud`. An explicit `account` object (or null) describes exported data without a host account lookup.
15
+ The agent is the iCloud account owner from `@chronicle.app/apple`. An explicit `account` object (or null) describes exported data without a host account lookup.
16
16
 
17
17
  Tests use a synthetic FoodNoms database with an explicit `account`, so they never read the host's FoodNoms data or iCloud account.
@@ -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 FoodNomsTransformer from './FoodNomsTransformer.js';
4
4
  export declare class FoodNomsExtractor extends SqliteExtractor<typeof FoodNomsExtractor> {
@@ -24,6 +24,16 @@ export declare class FoodNomsExtractor extends SqliteExtractor<typeof FoodNomsEx
24
24
  * correctly against the column's space-separated text.
25
25
  */
26
26
  private toDbDate;
27
+ /**
28
+ * Entries eaten within since and until, both inclusive. A food in a saved
29
+ * collection (a recipe, a saved meal) is an entry too, but one never eaten,
30
+ * with no time or day, so it's left out.
31
+ */
32
+ private entryFilter;
33
+ /** Whether the entry table has a column, which an older FoodNoms may not. */
34
+ private hasColumn;
27
35
  extract(): AsyncGenerator<Record>;
36
+ /** The items a run reads, each one food or drink logged, and when they were eaten. */
37
+ survey(options?: SurveyOptions): Promise<Survey | null>;
28
38
  }
29
39
  //# sourceMappingURL=FoodNomsExtractor.d.ts.map
@@ -1,5 +1,5 @@
1
- import { SqliteExtractor, timeRangeConditions } from '@chronicle.app/etl-sqlite';
2
- import { getICloudAccount } from '@chronicle.app/icloud';
1
+ import { SqliteExtractor, countsByMonth, monthSql, timeRangeConditions, } from '@chronicle.app/etl-sqlite';
2
+ import { getICloudAccount } from '@chronicle.app/apple';
3
3
  import { z } from 'zod';
4
4
  import FoodNomsTransformer from './FoodNomsTransformer.js';
5
5
  /**
@@ -16,6 +16,11 @@ const BUILTIN_MEAL_TYPES = {
16
16
  '3': 'Dinner',
17
17
  '4': 'Snacks',
18
18
  };
19
+ // When an entry was eaten: its time, or for one logged to a day without a
20
+ // time, the middle of that day in UTC, so it falls on that day in any zone
21
+ // within twelve hours. `day` is the day it was logged to, a Julian day number
22
+ // in FoodNoms, which strftime reads as it does a date.
23
+ const EATEN_AT = `COALESCE(f.date, strftime('%Y-%m-%d 12:00:00.000', f.day))`;
19
24
  export class FoodNomsExtractor extends SqliteExtractor {
20
25
  static source = 'foodnoms';
21
26
  static strategy = 'app-db';
@@ -60,19 +65,32 @@ export class FoodNomsExtractor extends SqliteExtractor {
60
65
  toDbDate(date) {
61
66
  return date.toISOString().replace('T', ' ').replace('Z', '');
62
67
  }
63
- async *extract() {
64
- if (!this.db) {
65
- throw new Error('Database not initialized');
66
- }
67
- const conditions = [`1=1`];
68
- const values = [];
69
- const range = timeRangeConditions('f.date', this.config, {
68
+ /**
69
+ * Entries eaten within since and until, both inclusive. A food in a saved
70
+ * collection (a recipe, a saved meal) is an entry too, but one never eaten,
71
+ * with no time or day, so it's left out.
72
+ */
73
+ entryFilter() {
74
+ const range = timeRangeConditions(EATEN_AT, this.config, {
70
75
  convert: date => this.toDbDate(date),
71
76
  sinceOp: '>=',
72
77
  untilOp: '<=',
73
78
  });
74
- conditions.push(...range.conditions);
75
- values.push(...range.values);
79
+ const collected = this.hasColumn('collectionEditID') ? ['f.collectionEditID IS NULL'] : [];
80
+ return {
81
+ where: [`1=1`, ...collected, ...range.conditions].join(' AND '),
82
+ values: range.values,
83
+ };
84
+ }
85
+ /** Whether the entry table has a column, which an older FoodNoms may not. */
86
+ hasColumn(name) {
87
+ return Boolean(this.db.prepare(`SELECT 1 FROM pragma_table_info('foodEntryRecord') WHERE name = ?`).get(name));
88
+ }
89
+ async *extract() {
90
+ if (!this.db) {
91
+ throw new Error('Database not initialized');
92
+ }
93
+ const { where, values } = this.entryFilter();
76
94
  const limit = this.getEffectiveLimit();
77
95
  if (limit !== null) {
78
96
  values.push(limit);
@@ -82,6 +100,7 @@ export class FoodNomsExtractor extends SqliteExtractor {
82
100
  f.id,
83
101
  hex(f.entryID) as entryID,
84
102
  f.date,
103
+ ${EATEN_AT} as eatenAt,
85
104
  f.dateCreated,
86
105
  f.dateLastUpdated,
87
106
  f.tzID,
@@ -111,8 +130,8 @@ export class FoodNomsExtractor extends SqliteExtractor {
111
130
  END as mealSortIndex
112
131
  FROM foodEntryRecord f
113
132
  LEFT JOIN mealTypeRecord m ON f.mealTypeID = m.mealTypeID
114
- WHERE ${conditions.join(' AND ')}
115
- ORDER BY f.date DESC
133
+ WHERE ${where}
134
+ ORDER BY ${EATEN_AT} DESC
116
135
  ${limit === null ? '' : 'LIMIT ?'}
117
136
  `;
118
137
  const rows = this.db.prepare(query).all(...values);
@@ -136,6 +155,7 @@ export class FoodNomsExtractor extends SqliteExtractor {
136
155
  entryID: this.hexToUuid(row.entryID),
137
156
  nutrients,
138
157
  date: row.date ? row.date + 'Z' : null,
158
+ eatenAt: row.eatenAt ? row.eatenAt + 'Z' : null,
139
159
  dateCreated: row.dateCreated ? row.dateCreated + 'Z' : null,
140
160
  dateLastUpdated: row.dateLastUpdated ? row.dateLastUpdated + 'Z' : null,
141
161
  };
@@ -162,4 +182,32 @@ export class FoodNomsExtractor extends SqliteExtractor {
162
182
  });
163
183
  }
164
184
  }
185
+ /** The items a run reads, each one food or drink logged, and when they were eaten. */
186
+ async survey(options = {}) {
187
+ if (!this.db)
188
+ return null;
189
+ const { where, values } = this.entryFilter();
190
+ const from = `FROM foodEntryRecord f
191
+ LEFT JOIN mealTypeRecord m ON f.mealTypeID = m.mealTypeID
192
+ WHERE ${where}`;
193
+ const row = this.db
194
+ .prepare(`SELECT COUNT(*) AS n, MIN(${EATEN_AT}) AS first, MAX(${EATEN_AT}) AS last ${from}`)
195
+ .get(...values);
196
+ // strftime reads the naive date strings as UTC too.
197
+ const month = monthSql(`CAST(strftime('%s', ${EATEN_AT}) AS INTEGER)`);
198
+ const byMonth = options.byMonth &&
199
+ countsByMonth(this.db
200
+ .prepare(`SELECT ${month} AS month, COUNT(*) AS n ${from} GROUP BY month`)
201
+ .all(...values));
202
+ // Naive UTC date strings, read as the UTC instants they are, as extract() does.
203
+ return {
204
+ meals: {
205
+ count: row.n,
206
+ label: 'items',
207
+ ...(row.first !== null && { earliest: new Date(row.first + 'Z') }),
208
+ ...(row.last !== null && { latest: new Date(row.last + 'Z') }),
209
+ ...(byMonth && { byMonth }),
210
+ },
211
+ };
212
+ }
165
213
  }
@@ -1,5 +1,5 @@
1
1
  import { ChronicleTransformer } from '@chronicle.app/etl';
2
- import { buildICloudPersonSchema } from '@chronicle.app/icloud';
2
+ import { buildICloudPersonSchema } from '@chronicle.app/apple';
3
3
  /**
4
4
  * Conservative seed list of common beverages, matched whole-word against an
5
5
  * entry's name. FoodNoms records NO food-vs-drink flag — verified there is no
@@ -69,7 +69,8 @@ export default class FoodNomsTransformer extends ChronicleTransformer {
69
69
  const meal = this.buildMeal(record.data, record.context);
70
70
  const user = await this.buildUser(record.context.account);
71
71
  // One consume action per logged food entry. FoodNoms gives a single point in
72
- // time (`date`), not a span, so it lands on `timestamp` rather than endTime.
72
+ // time (`date`), not a span, so it lands on `timestamp` rather than endTime:
73
+ // when it was eaten, which for an entry logged to a day alone is that day's.
73
74
  //
74
75
  // Identity is the consumption of this entry — keyed on the real entryID — and
75
76
  // `@type` is deliberately left OUT of the @key: eat-vs-drink is an inferred,
@@ -77,7 +78,7 @@ export default class FoodNomsTransformer extends ChronicleTransformer {
77
78
  // action. This also keeps FoodNoms consumptions shaped like the historical
78
79
  // `ate X` / `drank X` log so the two can sit on one timeline.
79
80
  const shared = {
80
- timestamp: new Date(record.data.date),
81
+ timestamp: new Date(record.data.eatenAt ?? record.data.date),
81
82
  '@key': ['source', 'sourceId'],
82
83
  source: 'foodnoms',
83
84
  sourceId: record.data.entryID,
@@ -107,16 +108,17 @@ export default class FoodNomsTransformer extends ChronicleTransformer {
107
108
  return buildICloudPersonSchema(account);
108
109
  }
109
110
  buildMeal(data, context) {
110
- // Key on `entryID`, the per-log CloudKit UUID FoodNoms assigns each entry.
111
- // `foodID` looks like a shared food reference but for the dominant namespaces
112
- // (`openai:` AI lookups, `local:` manual foods) it is a fresh UUID minted per
113
- // entry, so it neither dedupes repeats nor is a real cross-log identity —
114
- // using it would elevate a synthetic id to entity identity.
111
+ // Keyed by the consumption it was eaten in (`action.sourceId`, the entry's
112
+ // `entryID`, the per-log CloudKit UUID FoodNoms assigns each entry), which
113
+ // has that ID as its own. `foodID` looks like a shared food reference but
114
+ // for the dominant namespaces (`openai:` AI lookups, `local:` manual foods)
115
+ // it is a fresh UUID minted per entry, so it neither dedupes repeats nor is
116
+ // a real cross-log identity — using it would elevate a synthetic id to
117
+ // entity identity.
115
118
  const meal = {
116
119
  '@type': 'Meal',
117
- '@key': ['@type', 'source', 'sourceId'],
120
+ '@key': ['@type', 'source', 'action.sourceId'],
118
121
  source: 'foodnoms',
119
- sourceId: data.entryID,
120
122
  name: data.name,
121
123
  };
122
124
  // Classify the meal slot (Breakfast/Lunch/Dinner/Snacks) when known.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chronicle.app/foodnoms",
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": [
@@ -22,9 +22,9 @@
22
22
  "type": "module",
23
23
  "description": "Chronicle plugin for FoodNoms meal tracking data",
24
24
  "devDependencies": {
25
- "@chronicle.app/etl": "0.4.0",
26
- "@chronicle.app/schema": "0.4.0",
27
- "@chronicle.app/tsconfig": "0.4.0",
25
+ "@chronicle.app/etl": "0.5.0",
26
+ "@chronicle.app/schema": "0.5.0",
27
+ "@chronicle.app/tsconfig": "0.5.0",
28
28
  "@types/node": "^22.10.1",
29
29
  "typescript": "^5.7.2"
30
30
  },
@@ -39,8 +39,8 @@
39
39
  "registry": "https://registry.npmjs.org/"
40
40
  },
41
41
  "dependencies": {
42
- "@chronicle.app/etl-sqlite": "0.4.0",
43
- "@chronicle.app/icloud": "0.4.0",
42
+ "@chronicle.app/etl-sqlite": "0.5.0",
43
+ "@chronicle.app/apple": "0.5.0",
44
44
  "zod": "^3.24.1"
45
45
  },
46
46
  "peerDependencies": {
@@ -66,11 +66,19 @@
66
66
  "recordTypes": [
67
67
  "meals"
68
68
  ],
69
- "default": true
69
+ "default": true,
70
+ "locations": [
71
+ {
72
+ "path": "~/Library/Containers/com.algebraiclabs.foodnoms/Data/Documents/db.db"
73
+ }
74
+ ]
70
75
  }
71
76
  },
72
77
  "platforms": [
73
78
  "darwin"
79
+ ],
80
+ "requires": [
81
+ "full-disk-access"
74
82
  ]
75
83
  }
76
84
  }