@milldr/crono 0.1.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 +21 -0
- package/README.md +219 -0
- package/dist/commands/diary.d.ts +7 -0
- package/dist/commands/diary.d.ts.map +1 -0
- package/dist/commands/diary.js +79 -0
- package/dist/commands/diary.js.map +1 -0
- package/dist/commands/login.d.ts +19 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +160 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/quick-add.d.ts +8 -0
- package/dist/commands/quick-add.d.ts.map +1 -0
- package/dist/commands/quick-add.js +49 -0
- package/dist/commands/quick-add.js.map +1 -0
- package/dist/commands/weight.d.ts +7 -0
- package/dist/commands/weight.d.ts.map +1 -0
- package/dist/commands/weight.js +78 -0
- package/dist/commands/weight.js.map +1 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +43 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +27 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +178 -0
- package/dist/credentials.js.map +1 -0
- package/dist/debug-nav.d.ts +2 -0
- package/dist/debug-nav.d.ts.map +1 -0
- package/dist/debug-nav.js +99 -0
- package/dist/debug-nav.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel/client.d.ts +40 -0
- package/dist/kernel/client.d.ts.map +1 -0
- package/dist/kernel/client.js +205 -0
- package/dist/kernel/client.js.map +1 -0
- package/dist/kernel/diary.d.ts +22 -0
- package/dist/kernel/diary.d.ts.map +1 -0
- package/dist/kernel/diary.js +156 -0
- package/dist/kernel/diary.js.map +1 -0
- package/dist/kernel/login.d.ts +24 -0
- package/dist/kernel/login.d.ts.map +1 -0
- package/dist/kernel/login.js +125 -0
- package/dist/kernel/login.js.map +1 -0
- package/dist/kernel/quick-add.d.ts +22 -0
- package/dist/kernel/quick-add.d.ts.map +1 -0
- package/dist/kernel/quick-add.js +260 -0
- package/dist/kernel/quick-add.js.map +1 -0
- package/dist/kernel/weight.d.ts +20 -0
- package/dist/kernel/weight.d.ts.map +1 -0
- package/dist/kernel/weight.js +160 -0
- package/dist/kernel/weight.js.map +1 -0
- package/dist/utils/date.d.ts +31 -0
- package/dist/utils/date.d.ts.map +1 -0
- package/dist/utils/date.js +82 -0
- package/dist/utils/date.js.map +1 -0
- package/package.json +54 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright code generator for Cronometer quick-add automation.
|
|
3
|
+
*
|
|
4
|
+
* Returns a code string that executes remotely via
|
|
5
|
+
* kernel.browsers.playwright.execute(). The code has access to
|
|
6
|
+
* `page`, `context`, and `browser` from the Playwright environment.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Macro names as they appear in Cronometer's food search.
|
|
10
|
+
* Each macro is a separate "Quick Add" food item.
|
|
11
|
+
*/
|
|
12
|
+
export const MACRO_SEARCH_NAMES = {
|
|
13
|
+
protein: "Quick Add, Protein",
|
|
14
|
+
carbs: "Quick Add, Carbohydrate",
|
|
15
|
+
fat: "Quick Add, Fat",
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Generate Playwright code for adding a quick entry to Cronometer.
|
|
19
|
+
*
|
|
20
|
+
* For each macro, the flow is:
|
|
21
|
+
* right-click meal category → "Add Food" → search "Quick Add, <Macro>" →
|
|
22
|
+
* select result → enter serving size (grams) → "Add to Diary"
|
|
23
|
+
*/
|
|
24
|
+
export function buildQuickAddCode(entry) {
|
|
25
|
+
const { protein, carbs, fat, meal } = entry;
|
|
26
|
+
const mealLabel = meal
|
|
27
|
+
? meal.charAt(0).toUpperCase() + meal.slice(1).toLowerCase()
|
|
28
|
+
: "Uncategorized";
|
|
29
|
+
// Build list of macros to add
|
|
30
|
+
const macros = [];
|
|
31
|
+
if (protein !== undefined)
|
|
32
|
+
macros.push({
|
|
33
|
+
name: "protein",
|
|
34
|
+
searchName: MACRO_SEARCH_NAMES.protein,
|
|
35
|
+
grams: protein,
|
|
36
|
+
});
|
|
37
|
+
if (carbs !== undefined)
|
|
38
|
+
macros.push({
|
|
39
|
+
name: "carbs",
|
|
40
|
+
searchName: MACRO_SEARCH_NAMES.carbs,
|
|
41
|
+
grams: carbs,
|
|
42
|
+
});
|
|
43
|
+
if (fat !== undefined)
|
|
44
|
+
macros.push({
|
|
45
|
+
name: "fat",
|
|
46
|
+
searchName: MACRO_SEARCH_NAMES.fat,
|
|
47
|
+
grams: fat,
|
|
48
|
+
});
|
|
49
|
+
const macrosJson = JSON.stringify(macros);
|
|
50
|
+
return `
|
|
51
|
+
const macros = ${macrosJson};
|
|
52
|
+
const mealLabel = ${JSON.stringify(mealLabel)};
|
|
53
|
+
|
|
54
|
+
// Navigate to diary — we're already logged in from the same session
|
|
55
|
+
await page.goto('https://cronometer.com/#diary', { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
56
|
+
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
|
57
|
+
|
|
58
|
+
// Verify we're logged in
|
|
59
|
+
const url = page.url();
|
|
60
|
+
if (url.includes('/login') || url.includes('/signin')) {
|
|
61
|
+
return { success: false, error: 'Not logged in. Login may have failed.' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Helper: find and click an element from a list of selectors
|
|
65
|
+
async function clickFirst(selectors, description) {
|
|
66
|
+
for (const sel of selectors) {
|
|
67
|
+
try {
|
|
68
|
+
const el = page.locator(sel);
|
|
69
|
+
if (await el.count() > 0) {
|
|
70
|
+
await el.first().click();
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
} catch {}
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Helper: right-click an element from a list of selectors
|
|
79
|
+
async function rightClickFirst(selectors, description) {
|
|
80
|
+
for (const sel of selectors) {
|
|
81
|
+
try {
|
|
82
|
+
const el = page.locator(sel);
|
|
83
|
+
if (await el.count() > 0) {
|
|
84
|
+
await el.first().click({ button: 'right' });
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Add each macro as a separate food entry
|
|
93
|
+
for (const macro of macros) {
|
|
94
|
+
// Right-click the meal category
|
|
95
|
+
const clicked = await rightClickFirst([
|
|
96
|
+
'text="' + mealLabel + '"',
|
|
97
|
+
':has-text("' + mealLabel + '")',
|
|
98
|
+
]);
|
|
99
|
+
if (!clicked) {
|
|
100
|
+
return { success: false, error: 'Could not find meal category "' + mealLabel + '" in diary' };
|
|
101
|
+
}
|
|
102
|
+
await page.waitForSelector('text="Add Food..."', { timeout: 3000 }).catch(() =>
|
|
103
|
+
page.waitForSelector('text="Add Food"', { timeout: 2000 }).catch(() => {})
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Click "Add Food..." in context menu
|
|
107
|
+
const addFoodClicked = await clickFirst([
|
|
108
|
+
'text="Add Food..."',
|
|
109
|
+
'text="Add Food…"',
|
|
110
|
+
'text="Add Food"',
|
|
111
|
+
'[role="menuitem"]:has-text("Add Food")',
|
|
112
|
+
]);
|
|
113
|
+
if (!addFoodClicked) {
|
|
114
|
+
return { success: false, error: 'Could not find "Add Food" in context menu' };
|
|
115
|
+
}
|
|
116
|
+
await page.waitForTimeout(200);
|
|
117
|
+
|
|
118
|
+
// Wait for the "Add Food to Diary" dialog to appear
|
|
119
|
+
try {
|
|
120
|
+
await page.waitForSelector('text="Add Food to Diary"', { timeout: 5000 });
|
|
121
|
+
} catch {
|
|
122
|
+
return { success: false, error: 'Add Food to Diary dialog did not appear' };
|
|
123
|
+
}
|
|
124
|
+
await page.waitForTimeout(300);
|
|
125
|
+
|
|
126
|
+
// Click the search bar and type the search term
|
|
127
|
+
// GWT apps often need click + keyboard.type instead of fill()
|
|
128
|
+
const searchSelectors = [
|
|
129
|
+
'input[placeholder*="Search all foods" i]',
|
|
130
|
+
'input[placeholder*="Search" i]',
|
|
131
|
+
'input[placeholder*="food" i]',
|
|
132
|
+
'input.gwt-TextBox',
|
|
133
|
+
'input[type="text"]',
|
|
134
|
+
'input[type="search"]',
|
|
135
|
+
];
|
|
136
|
+
let searched = false;
|
|
137
|
+
for (const sel of searchSelectors) {
|
|
138
|
+
try {
|
|
139
|
+
const el = page.locator(sel);
|
|
140
|
+
if (await el.count() > 0) {
|
|
141
|
+
await el.first().click();
|
|
142
|
+
await page.waitForTimeout(200);
|
|
143
|
+
// Clear any existing text, then type via keyboard for GWT compatibility
|
|
144
|
+
await el.first().fill('');
|
|
145
|
+
await page.keyboard.type(macro.searchName, { delay: 50 });
|
|
146
|
+
searched = true;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
} catch {}
|
|
150
|
+
}
|
|
151
|
+
if (!searched) {
|
|
152
|
+
return { success: false, error: 'Could not find food search bar in Add Food dialog' };
|
|
153
|
+
}
|
|
154
|
+
await page.waitForTimeout(300);
|
|
155
|
+
|
|
156
|
+
// Click the SEARCH button to trigger results
|
|
157
|
+
await clickFirst([
|
|
158
|
+
'text="SEARCH"',
|
|
159
|
+
'button:has-text("SEARCH")',
|
|
160
|
+
'button:has-text("Search")',
|
|
161
|
+
]);
|
|
162
|
+
await page.waitForSelector('td:has-text("' + macro.searchName + '")', { timeout: 8000 }).catch(() => {});
|
|
163
|
+
|
|
164
|
+
// Select the search result row (not the search input)
|
|
165
|
+
// Target table rows/cells containing the macro name
|
|
166
|
+
const resultSelectors = [
|
|
167
|
+
'td:has-text("' + macro.searchName + '")',
|
|
168
|
+
'tr:has-text("' + macro.searchName + '") td',
|
|
169
|
+
'.gwt-HTML:has-text("' + macro.searchName + '")',
|
|
170
|
+
'div:has-text("' + macro.searchName + '"):not(:has(input))',
|
|
171
|
+
];
|
|
172
|
+
let resultClicked = false;
|
|
173
|
+
for (const sel of resultSelectors) {
|
|
174
|
+
try {
|
|
175
|
+
const el = page.locator(sel);
|
|
176
|
+
if (await el.count() > 0) {
|
|
177
|
+
await el.first().click();
|
|
178
|
+
resultClicked = true;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
} catch {}
|
|
182
|
+
}
|
|
183
|
+
if (!resultClicked) {
|
|
184
|
+
return { success: false, error: 'No search result found for "' + macro.searchName + '"' };
|
|
185
|
+
}
|
|
186
|
+
await page.waitForTimeout(200);
|
|
187
|
+
|
|
188
|
+
// Wait for the detail panel with serving size to appear
|
|
189
|
+
try {
|
|
190
|
+
await page.waitForSelector('text="Serving Size"', { timeout: 5000 });
|
|
191
|
+
} catch {
|
|
192
|
+
return { success: false, error: 'Serving Size panel did not appear for "' + macro.name + '"' };
|
|
193
|
+
}
|
|
194
|
+
await page.waitForTimeout(500);
|
|
195
|
+
|
|
196
|
+
// Enter the serving size (grams)
|
|
197
|
+
// Find the input by locating the "Serving Size" label's parent and
|
|
198
|
+
// then finding the input within it via page.evaluate()
|
|
199
|
+
let servingFilled = false;
|
|
200
|
+
try {
|
|
201
|
+
servingFilled = await page.evaluate((grams) => {
|
|
202
|
+
// Walk all elements containing "Serving Size" text
|
|
203
|
+
const walker = document.createTreeWalker(
|
|
204
|
+
document.body,
|
|
205
|
+
NodeFilter.SHOW_TEXT,
|
|
206
|
+
{ acceptNode: (node) =>
|
|
207
|
+
node.textContent && node.textContent.trim() === 'Serving Size'
|
|
208
|
+
? NodeFilter.FILTER_ACCEPT
|
|
209
|
+
: NodeFilter.FILTER_REJECT
|
|
210
|
+
}
|
|
211
|
+
);
|
|
212
|
+
const textNode = walker.nextNode();
|
|
213
|
+
if (!textNode) return false;
|
|
214
|
+
|
|
215
|
+
// Walk up to find a container that also has an input
|
|
216
|
+
let container = textNode.parentElement;
|
|
217
|
+
for (let i = 0; i < 5 && container; i++) {
|
|
218
|
+
const input = container.querySelector('input');
|
|
219
|
+
if (input) {
|
|
220
|
+
input.focus();
|
|
221
|
+
input.select();
|
|
222
|
+
// Use native input setter to trigger GWT's change detection
|
|
223
|
+
const nativeSetter = Object.getOwnPropertyDescriptor(
|
|
224
|
+
window.HTMLInputElement.prototype, 'value'
|
|
225
|
+
).set;
|
|
226
|
+
nativeSetter.call(input, String(grams));
|
|
227
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
228
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
container = container.parentElement;
|
|
232
|
+
}
|
|
233
|
+
return false;
|
|
234
|
+
}, macro.grams);
|
|
235
|
+
} catch {}
|
|
236
|
+
|
|
237
|
+
if (!servingFilled) {
|
|
238
|
+
return { success: false, error: 'Could not find serving size input for "' + macro.name + '"' };
|
|
239
|
+
}
|
|
240
|
+
await page.waitForTimeout(500);
|
|
241
|
+
|
|
242
|
+
// Click "ADD TO DIARY"
|
|
243
|
+
const addClicked = await clickFirst([
|
|
244
|
+
'button:has-text("ADD TO DIARY")',
|
|
245
|
+
'button:has-text("Add to Diary")',
|
|
246
|
+
'text="ADD TO DIARY"',
|
|
247
|
+
'text="Add to Diary"',
|
|
248
|
+
'button[type="submit"]',
|
|
249
|
+
]);
|
|
250
|
+
if (!addClicked) {
|
|
251
|
+
return { success: false, error: 'Could not find "Add to Diary" button for "' + macro.name + '"' };
|
|
252
|
+
}
|
|
253
|
+
await page.waitForSelector('text="Add Food to Diary"', { state: 'hidden', timeout: 8000 }).catch(() => {});
|
|
254
|
+
await page.waitForTimeout(300);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { success: true };
|
|
258
|
+
`;
|
|
259
|
+
}
|
|
260
|
+
//# sourceMappingURL=quick-add.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"quick-add.js","sourceRoot":"","sources":["../../src/kernel/quick-add.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH;;;GAGG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAA2B;IACxD,OAAO,EAAE,oBAAoB;IAC7B,KAAK,EAAE,yBAAyB;IAChC,GAAG,EAAE,gBAAgB;CACtB,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAiB;IACjD,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,GAAG,KAAK,CAAC;IAE5C,MAAM,SAAS,GAAG,IAAI;QACpB,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE;QAC5D,CAAC,CAAC,eAAe,CAAC;IAEpB,8BAA8B;IAC9B,MAAM,MAAM,GAA0D,EAAE,CAAC;IACzE,IAAI,OAAO,KAAK,SAAS;QACvB,MAAM,CAAC,IAAI,CAAC;YACV,IAAI,EAAE,SAAS;YACf,UAAU,EAAE,kBAAkB,CAAC,OAAO;YACtC,KAAK,EAAE,OAAO;SACf,CAAC,CAAC;IACL,IAAI,KAAK,KAAK,SAAS;QACrB,MAAM,CAAC,IAAI,CAAC;YACV,IAAI,EAAE,OAAO;YACb,UAAU,EAAE,kBAAkB,CAAC,KAAK;YACpC,KAAK,EAAE,KAAK;SACb,CAAC,CAAC;IACL,IAAI,GAAG,KAAK,SAAS;QACnB,MAAM,CAAC,IAAI,CAAC;YACV,IAAI,EAAE,KAAK;YACX,UAAU,EAAE,kBAAkB,CAAC,GAAG;YAClC,KAAK,EAAE,GAAG;SACX,CAAC,CAAC;IAEL,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;IAE1C,OAAO;qBACY,UAAU;wBACP,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8M9C,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright code generator for Cronometer weight scraping.
|
|
3
|
+
*
|
|
4
|
+
* Returns a code string that executes remotely via
|
|
5
|
+
* kernel.browsers.playwright.execute(). The code has access to
|
|
6
|
+
* `page`, `context`, and `browser` from the Playwright environment.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Generate Playwright code for reading weight data from Cronometer.
|
|
10
|
+
*
|
|
11
|
+
* For each date, the flow is:
|
|
12
|
+
* navigate to diary → set date via prev/next arrows → read weight row
|
|
13
|
+
*
|
|
14
|
+
* Cronometer diary layout (biometric rows):
|
|
15
|
+
* <tr> <td>time</td> <td>icon</td> <td>Weight</td> <td>212.5</td> <td>lbs</td> ... </tr>
|
|
16
|
+
*
|
|
17
|
+
* Returns { success: true, entries: [{ date, weight, unit }] }
|
|
18
|
+
*/
|
|
19
|
+
export declare function buildWeightCode(dates: string[]): string;
|
|
20
|
+
//# sourceMappingURL=weight.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"weight.d.ts","sourceRoot":"","sources":["../../src/kernel/weight.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CA6IvD"}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright code generator for Cronometer weight scraping.
|
|
3
|
+
*
|
|
4
|
+
* Returns a code string that executes remotely via
|
|
5
|
+
* kernel.browsers.playwright.execute(). The code has access to
|
|
6
|
+
* `page`, `context`, and `browser` from the Playwright environment.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Generate Playwright code for reading weight data from Cronometer.
|
|
10
|
+
*
|
|
11
|
+
* For each date, the flow is:
|
|
12
|
+
* navigate to diary → set date via prev/next arrows → read weight row
|
|
13
|
+
*
|
|
14
|
+
* Cronometer diary layout (biometric rows):
|
|
15
|
+
* <tr> <td>time</td> <td>icon</td> <td>Weight</td> <td>212.5</td> <td>lbs</td> ... </tr>
|
|
16
|
+
*
|
|
17
|
+
* Returns { success: true, entries: [{ date, weight, unit }] }
|
|
18
|
+
*/
|
|
19
|
+
export function buildWeightCode(dates) {
|
|
20
|
+
const datesJson = JSON.stringify(dates);
|
|
21
|
+
return `
|
|
22
|
+
const dates = ${datesJson};
|
|
23
|
+
const entries = [];
|
|
24
|
+
|
|
25
|
+
// Navigate to diary — we're already logged in from the same session
|
|
26
|
+
await page.goto('https://cronometer.com/#diary', { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
27
|
+
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
|
28
|
+
|
|
29
|
+
// Verify we're logged in
|
|
30
|
+
const url = page.url();
|
|
31
|
+
if (url.includes('/login') || url.includes('/signin')) {
|
|
32
|
+
return { success: false, error: 'Not logged in. Login may have failed.' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Wait for the diary to fully render
|
|
36
|
+
await page.waitForTimeout(2000);
|
|
37
|
+
|
|
38
|
+
// Helper: click the previous-day arrow.
|
|
39
|
+
// Cronometer diary uses <i> icon-font elements for date nav:
|
|
40
|
+
// <i class="icon-chevron-left diary-date-previous">
|
|
41
|
+
// <i class="icon-chevron-right diary-date-next">
|
|
42
|
+
async function clickPrevDay() {
|
|
43
|
+
const prev = page.locator('i.diary-date-previous').filter({ visible: true });
|
|
44
|
+
if (await prev.count() > 0) {
|
|
45
|
+
await prev.first().click();
|
|
46
|
+
await page.waitForTimeout(2000);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Helper: click the next-day arrow
|
|
53
|
+
async function clickNextDay() {
|
|
54
|
+
const next = page.locator('i.diary-date-next').filter({ visible: true });
|
|
55
|
+
if (await next.count() > 0) {
|
|
56
|
+
await next.first().click();
|
|
57
|
+
await page.waitForTimeout(2000);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Helper: extract weight from the currently displayed diary page.
|
|
64
|
+
// Cronometer shows biometrics as table rows:
|
|
65
|
+
// <tr> <td>time</td> <td>icon</td> <td>Weight</td> <td>212.5</td> <td>lbs</td> </tr>
|
|
66
|
+
async function extractWeight() {
|
|
67
|
+
return await page.evaluate(() => {
|
|
68
|
+
// Strategy 1: Find a table row containing "Weight" and extract value + unit
|
|
69
|
+
const rows = document.querySelectorAll('tr');
|
|
70
|
+
for (const row of rows) {
|
|
71
|
+
const cells = row.querySelectorAll('td');
|
|
72
|
+
let foundWeight = false;
|
|
73
|
+
let value = null;
|
|
74
|
+
let unit = null;
|
|
75
|
+
for (const cell of cells) {
|
|
76
|
+
const text = (cell.textContent || '').trim();
|
|
77
|
+
if (/^Weight$/i.test(text)) {
|
|
78
|
+
foundWeight = true;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (foundWeight && !value && /^\\d+\\.?\\d*$/.test(text)) {
|
|
82
|
+
value = parseFloat(text);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (foundWeight && value && !unit && /^(lbs?|kg)$/i.test(text)) {
|
|
86
|
+
unit = text.toLowerCase();
|
|
87
|
+
if (unit === 'lb') unit = 'lbs';
|
|
88
|
+
return { weight: value, unit };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// If we found Weight and a number but no separate unit cell,
|
|
92
|
+
// check if the number cell also contains the unit
|
|
93
|
+
if (foundWeight && value && !unit) {
|
|
94
|
+
return { weight: value, unit: 'lbs' };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Strategy 2: Search the full page text for "Weight\\t<number>\\t<unit>"
|
|
99
|
+
const bodyText = document.body.innerText;
|
|
100
|
+
const match = bodyText.match(/\\bWeight\\b[^\\S\\n]*[\\t]+[^\\S\\n]*([\\d.]+)[^\\S\\n]*[\\t]+[^\\S\\n]*(lbs?|kg)/i);
|
|
101
|
+
if (match) {
|
|
102
|
+
let u = match[2].toLowerCase();
|
|
103
|
+
if (u === 'lb') u = 'lbs';
|
|
104
|
+
return { weight: parseFloat(match[1]), unit: u };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Strategy 3: Look for "Weight" on a line with a number
|
|
108
|
+
const lines = bodyText.split('\\n');
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
if (/\\bWeight\\b/i.test(line) && !/Heart Rate|Respiration|Oxygen|Sleep|Walking|VO2|Resting/i.test(line)) {
|
|
111
|
+
const m = line.match(/(\\d+\\.\\d+|\\d+)\\s*(lbs?|kg)/i);
|
|
112
|
+
if (m) {
|
|
113
|
+
let u = m[2].toLowerCase();
|
|
114
|
+
if (u === 'lb') u = 'lbs';
|
|
115
|
+
return { weight: parseFloat(m[1]), unit: u };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return null;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Diary opens to today. Dates are in descending order (most recent first).
|
|
125
|
+
// Calculate how many days back from today to the first requested date,
|
|
126
|
+
// then step one day back for each subsequent date.
|
|
127
|
+
const today = new Date();
|
|
128
|
+
today.setHours(0, 0, 0, 0);
|
|
129
|
+
|
|
130
|
+
for (let i = 0; i < dates.length; i++) {
|
|
131
|
+
const targetDate = dates[i];
|
|
132
|
+
|
|
133
|
+
if (i === 0) {
|
|
134
|
+
// Navigate from today to the first (most recent) requested date
|
|
135
|
+
const target = new Date(targetDate + 'T00:00:00');
|
|
136
|
+
const daysBack = Math.round((today - target) / (1000 * 60 * 60 * 24));
|
|
137
|
+
for (let s = 0; s < daysBack && s < 90; s++) {
|
|
138
|
+
await clickPrevDay();
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
// Step backward one day from previous date
|
|
142
|
+
await clickPrevDay();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const data = await extractWeight();
|
|
147
|
+
if (data) {
|
|
148
|
+
entries.push({ date: targetDate, weight: data.weight, unit: data.unit });
|
|
149
|
+
} else {
|
|
150
|
+
entries.push({ date: targetDate, weight: null, unit: 'lbs' });
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
entries.push({ date: targetDate, weight: null, unit: 'lbs' });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { success: true, entries };
|
|
158
|
+
`;
|
|
159
|
+
}
|
|
160
|
+
//# sourceMappingURL=weight.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"weight.js","sourceRoot":"","sources":["../../src/kernel/weight.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;;;;GAUG;AACH,MAAM,UAAU,eAAe,CAAC,KAAe;IAC7C,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;IAExC,OAAO;oBACW,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwI1B,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date validation and range parsing utilities.
|
|
3
|
+
*
|
|
4
|
+
* Shared by `weight` and future `diary` commands.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Validate a YYYY-MM-DD string and return it.
|
|
8
|
+
* Throws if the format is invalid or the date doesn't exist.
|
|
9
|
+
*/
|
|
10
|
+
export declare function parseDate(str: string): string;
|
|
11
|
+
/** Format a Date object as YYYY-MM-DD. */
|
|
12
|
+
export declare function formatDate(date: Date): string;
|
|
13
|
+
/** Return today's date as YYYY-MM-DD. */
|
|
14
|
+
export declare function todayStr(): string;
|
|
15
|
+
/**
|
|
16
|
+
* Parse a range spec into start/end date strings.
|
|
17
|
+
*
|
|
18
|
+
* Supports:
|
|
19
|
+
* - Relative: "7d", "30d" — last N days inclusive of today
|
|
20
|
+
* - Absolute: "2026-01-15:2026-02-10"
|
|
21
|
+
*/
|
|
22
|
+
export declare function parseRange(spec: string): {
|
|
23
|
+
start: string;
|
|
24
|
+
end: string;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Generate an array of YYYY-MM-DD strings between start and end (inclusive),
|
|
28
|
+
* in descending order (most recent first).
|
|
29
|
+
*/
|
|
30
|
+
export declare function dateRange(start: string, end: string): string[];
|
|
31
|
+
//# sourceMappingURL=date.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"date.d.ts","sourceRoot":"","sources":["../../src/utils/date.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAMH;;;GAGG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAa7C;AAED,0CAA0C;AAC1C,wBAAgB,UAAU,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAK7C;AAED,yCAAyC;AACzC,wBAAgB,QAAQ,IAAI,MAAM,CAEjC;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CA0BvE;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAW9D"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date validation and range parsing utilities.
|
|
3
|
+
*
|
|
4
|
+
* Shared by `weight` and future `diary` commands.
|
|
5
|
+
*/
|
|
6
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
7
|
+
const RELATIVE_RE = /^(\d+)d$/;
|
|
8
|
+
const ABSOLUTE_RE = /^(\d{4}-\d{2}-\d{2}):(\d{4}-\d{2}-\d{2})$/;
|
|
9
|
+
/**
|
|
10
|
+
* Validate a YYYY-MM-DD string and return it.
|
|
11
|
+
* Throws if the format is invalid or the date doesn't exist.
|
|
12
|
+
*/
|
|
13
|
+
export function parseDate(str) {
|
|
14
|
+
if (!DATE_RE.test(str)) {
|
|
15
|
+
throw new Error(`Invalid date format "${str}". Use YYYY-MM-DD`);
|
|
16
|
+
}
|
|
17
|
+
const d = new Date(str + "T00:00:00");
|
|
18
|
+
if (isNaN(d.getTime())) {
|
|
19
|
+
throw new Error(`Invalid date "${str}"`);
|
|
20
|
+
}
|
|
21
|
+
// Verify the parsed date matches the input (catches things like 2026-02-30)
|
|
22
|
+
if (formatDate(d) !== str) {
|
|
23
|
+
throw new Error(`Invalid date "${str}"`);
|
|
24
|
+
}
|
|
25
|
+
return str;
|
|
26
|
+
}
|
|
27
|
+
/** Format a Date object as YYYY-MM-DD. */
|
|
28
|
+
export function formatDate(date) {
|
|
29
|
+
const y = date.getFullYear();
|
|
30
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
31
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
32
|
+
return `${y}-${m}-${d}`;
|
|
33
|
+
}
|
|
34
|
+
/** Return today's date as YYYY-MM-DD. */
|
|
35
|
+
export function todayStr() {
|
|
36
|
+
return formatDate(new Date());
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Parse a range spec into start/end date strings.
|
|
40
|
+
*
|
|
41
|
+
* Supports:
|
|
42
|
+
* - Relative: "7d", "30d" — last N days inclusive of today
|
|
43
|
+
* - Absolute: "2026-01-15:2026-02-10"
|
|
44
|
+
*/
|
|
45
|
+
export function parseRange(spec) {
|
|
46
|
+
const relMatch = spec.match(RELATIVE_RE);
|
|
47
|
+
if (relMatch) {
|
|
48
|
+
const days = parseInt(relMatch[1], 10);
|
|
49
|
+
if (days <= 0) {
|
|
50
|
+
throw new Error(`Invalid range "${spec}". Day count must be positive`);
|
|
51
|
+
}
|
|
52
|
+
const end = new Date();
|
|
53
|
+
const start = new Date();
|
|
54
|
+
start.setDate(start.getDate() - (days - 1));
|
|
55
|
+
return { start: formatDate(start), end: formatDate(end) };
|
|
56
|
+
}
|
|
57
|
+
const absMatch = spec.match(ABSOLUTE_RE);
|
|
58
|
+
if (absMatch) {
|
|
59
|
+
const start = parseDate(absMatch[1]);
|
|
60
|
+
const end = parseDate(absMatch[2]);
|
|
61
|
+
if (start > end) {
|
|
62
|
+
throw new Error(`Invalid range: start "${start}" is after end "${end}"`);
|
|
63
|
+
}
|
|
64
|
+
return { start, end };
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`Invalid range format "${spec}". Use '7d', '30d', or 'YYYY-MM-DD:YYYY-MM-DD'`);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Generate an array of YYYY-MM-DD strings between start and end (inclusive),
|
|
70
|
+
* in descending order (most recent first).
|
|
71
|
+
*/
|
|
72
|
+
export function dateRange(start, end) {
|
|
73
|
+
const dates = [];
|
|
74
|
+
const cur = new Date(end + "T00:00:00");
|
|
75
|
+
const startDate = new Date(start + "T00:00:00");
|
|
76
|
+
while (cur >= startDate) {
|
|
77
|
+
dates.push(formatDate(cur));
|
|
78
|
+
cur.setDate(cur.getDate() - 1);
|
|
79
|
+
}
|
|
80
|
+
return dates;
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=date.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"date.js","sourceRoot":"","sources":["../../src/utils/date.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@milldr/crono",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for Cronometer automation via Kernel.sh",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"crono": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"dev": "tsx src/index.ts",
|
|
15
|
+
"lint": "eslint src/",
|
|
16
|
+
"format": "prettier --write .",
|
|
17
|
+
"format:check": "prettier --check .",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"test:watch": "vitest",
|
|
20
|
+
"prepare": "husky"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"cronometer",
|
|
24
|
+
"nutrition",
|
|
25
|
+
"cli",
|
|
26
|
+
"automation",
|
|
27
|
+
"kernel"
|
|
28
|
+
],
|
|
29
|
+
"author": "Daniel Miller",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@clack/prompts": "^1.0.0",
|
|
36
|
+
"@napi-rs/keyring": "^1.2.0",
|
|
37
|
+
"@onkernel/sdk": "^0.33.0",
|
|
38
|
+
"commander": "^12.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^20.11.0",
|
|
42
|
+
"eslint": "^8.56.0",
|
|
43
|
+
"husky": "^9.1.7",
|
|
44
|
+
"lint-staged": "^16.2.7",
|
|
45
|
+
"prettier": "^3.2.0",
|
|
46
|
+
"tsx": "^4.7.0",
|
|
47
|
+
"typescript": "^5.3.0",
|
|
48
|
+
"typescript-eslint": "^8.55.0",
|
|
49
|
+
"vitest": "^1.2.0"
|
|
50
|
+
},
|
|
51
|
+
"lint-staged": {
|
|
52
|
+
"*": "prettier --check --ignore-unknown"
|
|
53
|
+
}
|
|
54
|
+
}
|