diff --git a/README.md b/README.md index 5165ec3..703bef3 100644 --- a/README.md +++ b/README.md @@ -393,7 +393,7 @@ crono export [options] | Type | Granularity | Description | | ------------ | ------------------ | --------------------------------------------------------------------------------------------- | | `nutrition` | Daily totals | Aggregated calories + 60+ nutrient columns (vitamins, minerals, amino acids, omega 3/6, etc.) | -| `servings` | Per food entry | Time, meal, food name, amount, full nutrient breakdown — answers "what did I have for dinner" | +| `servings` | Per food entry | Time, meal, food name, amount, and nutrient columns when supplied by Cronometer | | `exercises` | Per exercise entry | Time, exercise name, duration, calories burned, group | | `biometrics` | Per measurement | Weight, BP, plus anything Apple Health pushes in (heart rate, HRV, sleep) | @@ -409,6 +409,12 @@ crono export [options] `-d` and `-r` are mutually exclusive. `--csv` and `--json` are mutually exclusive. +Cronometer may return a servings CSV with no nutrient columns. In that case, +JSON nutrient fields are `null`, not zero, and text output reports unknown +values without inventing totals. Blank or invalid nutrient values are also +unknown. Use the live food details and diary to verify nutrition when the +export omits it; do not treat `null` as a zero-calorie food. + **Examples:** ```bash @@ -428,7 +434,7 @@ crono export servings -m Dinner # Yesterday's full food log crono export servings -d yesterday -# Last 7 days of food entries as JSON (each entry includes all 60+ nutrient columns) +# Last 7 days of food entries as JSON (includes nutrient columns when available) crono export servings -r 7d --json # Today's exercises @@ -476,6 +482,18 @@ export CRONO_GWT_HEADER= ## Development +Food writes are non-idempotent. If `add custom-food --log` times out, the +custom food may already exist even when no diary entry is visible. Treat the +outcome as **unconfirmed**: wait for the original operation to stop, check the +food catalog, and compare a fresh unfiltered servings export for the exact diary +date with your pre-write baseline before considering any retry. Never recreate +the food based only on a missing diary row. + +Food-dialog actions skip hidden controls and use bounded waits. Standalone +logging has a 180-second operation budget; custom-food creation has 120 seconds, +or 300 seconds when combined with logging. These budgets exclude login time; +increasing them does not make retries safe. + ```bash git clone https://github.com/milldr/crono.git cd crono @@ -494,6 +512,13 @@ npm test npm run build ``` +For the unit suite in an authenticated shell, exclude credential overrides so +the credential-store tests remain isolated: + +```bash +env -u KERNEL_API_KEY -u CRONO_CRONOMETER_USERNAME -u CRONO_CRONOMETER_PASSWORD npm test +``` + ## Support I build and maintain projects like crono in my free time as personal hobbies. They're completely free and always will be. If you find this useful and want to show some support, feel free to buy me a coffee: diff --git a/src/automation/runner.ts b/src/automation/runner.ts index 9a2727f..e142e61 100644 --- a/src/automation/runner.ts +++ b/src/automation/runner.ts @@ -128,7 +128,7 @@ export function createAutomationClient( const data = await executeAutomation<{ success: boolean; error?: string; - }>(runtime, buildAddCustomFoodCode(entry), 120); + }>(runtime, buildAddCustomFoodCode(entry), entry.log ? 300 : 120); if (!data.success) { throw new Error( `Custom food creation failed: ${data.error ?? "Unknown error"}` @@ -141,7 +141,7 @@ export function createAutomationClient( const data = await executeAutomation<{ success: boolean; error?: string; - }>(runtime, buildLogFoodCode(entry), 60); + }>(runtime, buildLogFoodCode(entry), 180); if (!data.success) { throw new Error( `Food logging failed: ${data.error ?? "Unknown error"}` diff --git a/src/commands/add.ts b/src/commands/add.ts index acdd346..cfa411f 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -81,8 +81,11 @@ export async function addCustomFood( p.outro(`Created custom food: ${name} (${macroDisplay})`); } } catch (error) { - s.stop("Failed."); - p.log.error(`Failed to create custom food: ${formatKernelError(error)}`); + s.stop("Unconfirmed."); + p.log.error(`Custom food outcome unconfirmed: ${formatKernelError(error)}`); + p.log.warn( + "The food may have been saved even if diary logging did not finish. Do not recreate it or retry logging until the original operation has stopped and both the food catalog and exact-date diary have been checked. An absent diary row does not prove food creation failed." + ); process.exit(1); } } diff --git a/src/commands/export.ts b/src/commands/export.ts index b480d67..285c4e2 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -198,16 +198,26 @@ function formatServings(entries: ServingEntry[], isRange: boolean): void { for (const e of entries) { const prefix = datePrefix ? `${e.date} ` : ""; p.log.info( - `${prefix}${e.time} | ${e.meal} | ${e.food} | ${e.amount} | ${e.calories} kcal | P: ${e.protein}g C: ${e.carbs}g F: ${e.fat}g` + `${prefix}${e.time} | ${e.meal} | ${e.food} | ${e.amount} | ${e.calories ?? "unknown"} kcal | P: ${e.protein ?? "unknown"}g C: ${e.carbs ?? "unknown"}g F: ${e.fat ?? "unknown"}g` ); } // Totals (useful when filtering by meal or showing one day) if (entries.length > 1) { - const totalCal = entries.reduce((s, e) => s + e.calories, 0); - const totalP = entries.reduce((s, e) => s + e.protein, 0); - const totalC = entries.reduce((s, e) => s + e.carbs, 0); - const totalF = entries.reduce((s, e) => s + e.fat, 0); + if ( + entries.some((e) => + [e.calories, e.protein, e.carbs, e.fat].includes(null) + ) + ) { + p.log.info( + "Nutrition totals unavailable: export omits or contains invalid nutrient values." + ); + return; + } + const totalCal = entries.reduce((s, e) => s + (e.calories ?? 0), 0); + const totalP = entries.reduce((s, e) => s + (e.protein ?? 0), 0); + const totalC = entries.reduce((s, e) => s + (e.carbs ?? 0), 0); + const totalF = entries.reduce((s, e) => s + (e.fat ?? 0), 0); p.log.info("───"); p.log.info( `Total: ${totalCal.toFixed(0)} kcal | P: ${totalP.toFixed(1)}g C: ${totalC.toFixed(1)}g F: ${totalF.toFixed(1)}g` diff --git a/src/commands/log.ts b/src/commands/log.ts index 1cb0944..6d6832d 100644 --- a/src/commands/log.ts +++ b/src/commands/log.ts @@ -50,8 +50,13 @@ export async function log(name: string, options: LogOptions): Promise { s.stop("Done."); p.outro(`Logged: ${name} → ${mealLabel}`); } catch (error) { - s.stop("Failed."); - p.log.error(`Failed to log food: ${formatKernelError(error)}`); + s.stop("Unconfirmed."); + p.log.error( + `Food logging outcome unconfirmed: ${formatKernelError(error)}` + ); + p.log.warn( + "Do not retry until the original operation has stopped and a fresh unfiltered export for the exact diary date confirms no entry was saved. A timeout is not proof that the write failed." + ); process.exit(1); } } diff --git a/src/cronometer/parse.ts b/src/cronometer/parse.ts index 8067c0a..8fa72c6 100644 --- a/src/cronometer/parse.ts +++ b/src/cronometer/parse.ts @@ -37,12 +37,12 @@ export interface ServingEntry { meal: string; food: string; amount: string; - calories: number; - protein: number; - carbs: number; - fat: number; + calories: number | null; + protein: number | null; + carbs: number | null; + fat: number | null; category: string; - [key: string]: string | number; + [key: string]: string | number | null; } /** Parse a CSV string into rows of string arrays. Handles quoted fields. */ @@ -94,6 +94,12 @@ function num(value: string | undefined): number { return isNaN(n) ? 0 : n; } +function optionalNutrient(value: string | undefined): number | null { + if (value === undefined || value.trim() === "") return null; + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : null; +} + export function parseNutrition(csv: string): NutritionEntry[] { const rows = parseCSV(csv); if (rows.length < 2) return []; @@ -205,10 +211,10 @@ export function parseServings(csv: string): ServingEntry[] { meal: row[groupIdx]?.trim() ?? "", food: row[foodIdx]?.trim() ?? "", amount: row[amountIdx]?.trim() ?? "", - calories: num(row[energyIdx]), - protein: num(row[proteinIdx]), - carbs: num(row[carbsIdx]), - fat: num(row[fatIdx]), + calories: optionalNutrient(row[energyIdx]), + protein: optionalNutrient(row[proteinIdx]), + carbs: optionalNutrient(row[carbsIdx]), + fat: optionalNutrient(row[fatIdx]), category: row[categoryIdx]?.trim() ?? "", }; diff --git a/src/kernel/add-custom-food.ts b/src/kernel/add-custom-food.ts index a9c54e6..d68d3a8 100644 --- a/src/kernel/add-custom-food.ts +++ b/src/kernel/add-custom-food.ts @@ -72,9 +72,9 @@ export function buildAddCustomFoodCode(entry: CustomFoodEntry): string { async function clickFirst(selectors, description) { for (const sel of selectors) { try { - const el = page.locator(sel); + const el = page.locator(sel).filter({ visible: true }); if (await el.count() > 0) { - await el.first().click(); + await el.first().click({ timeout: 3000 }); return true; } } catch {} @@ -195,6 +195,7 @@ export function buildAddCustomFoodCode(entry: CustomFoodEntry): string { ${buildFoodDialogCode({ errorPrefix: "Food created but ", requireServingSize: false, + verifyDialogDismissed: true, })} } diff --git a/src/kernel/food-dialog.ts b/src/kernel/food-dialog.ts index 97bff57..abdac53 100644 --- a/src/kernel/food-dialog.ts +++ b/src/kernel/food-dialog.ts @@ -51,7 +51,7 @@ export function buildFoodDialogCode(options: FoodDialogOptions = {}): string { async function rightClickFirst(selectors, description) { for (const sel of selectors) { try { - const el = page.locator(sel); + const el = page.locator(sel).filter({ visible: true }); if (await el.count() > 0) { await el.first().click({ button: 'right', timeout: 5000 }); return true; @@ -122,11 +122,11 @@ export function buildFoodDialogCode(options: FoodDialogOptions = {}): string { let searched = false; for (const sel of searchSelectors) { try { - const el = page.locator(sel); + const el = page.locator(sel).filter({ visible: true }); if (await el.count() > 0) { - await el.first().click(); + await el.first().click({ timeout: 3000 }); await page.waitForTimeout(200); - await el.first().fill(''); + await el.first().fill('', { timeout: 3000 }); await page.keyboard.type(${foodNameVar}, { delay: 50 }); searched = true; break; @@ -140,13 +140,14 @@ export function buildFoodDialogCode(options: FoodDialogOptions = {}): string { // Click SEARCH await clickFirst([ - 'text="SEARCH")', 'button:has-text("SEARCH")', 'button:has-text("Search")', + 'text="SEARCH"', ], 'SEARCH button'); // Wait for search results to appear - const resultsAppeared = await page.waitForSelector('td', { timeout: 8000 }) + const resultsAppeared = await page.getByText(${foodNameVar}, { exact: true }) + .filter({ visible: true }).first().waitFor({ state: 'visible', timeout: 8000 }) .then(() => true) .catch(() => false); if (!resultsAppeared) { @@ -179,7 +180,7 @@ export function buildFoodDialogCode(options: FoodDialogOptions = {}): string { console.log('[crono food-dialog] found matching search result at row ' + i + ': ' + description); // GWT rows don't respond to click(), need to focus and press Enter - await row.focus(); + await row.focus({ timeout: 3000 }); await page.waitForTimeout(300); await page.keyboard.press('Enter'); resultClicked = true; @@ -208,7 +209,7 @@ ${buildServingSizeCode({ errorPrefix, foodNameVar, itemNameVar, requireServingSi const buttonClicked = await page.evaluate(() => { const buttons = document.querySelectorAll('button'); for (const btn of buttons) { - if (btn.textContent && btn.textContent.trim() === 'Add to Diary' && btn.offsetParent !== null) { + if (btn.textContent && btn.textContent.trim().toLowerCase() === 'add to diary' && btn.offsetParent !== null && !btn.disabled) { btn.click(); return true; } diff --git a/src/kernel/log-food.ts b/src/kernel/log-food.ts index 8a12dc4..b35f55c 100644 --- a/src/kernel/log-food.ts +++ b/src/kernel/log-food.ts @@ -45,9 +45,9 @@ export function buildLogFoodCode(entry: LogFoodEntry): string { async function clickFirst(selectors, description) { for (const sel of selectors) { try { - const el = page.locator(sel); + const el = page.locator(sel).filter({ visible: true }); if (await el.count() > 0) { - await el.first().click(); + await el.first().click({ timeout: 3000 }); return true; } } catch {} @@ -55,7 +55,7 @@ export function buildLogFoodCode(entry: LogFoodEntry): string { return false; } -${buildFoodDialogCode({ updateServingSize: true })} +${buildFoodDialogCode({ updateServingSize: true, verifyDialogDismissed: true })} return { success: true }; `; diff --git a/tests/automation/runner.test.ts b/tests/automation/runner.test.ts index a077674..a33d83f 100644 --- a/tests/automation/runner.test.ts +++ b/tests/automation/runner.test.ts @@ -64,3 +64,58 @@ describe("quick-add automation timeouts", () => { expect(close).toHaveBeenCalledOnce(); }); }); + +describe("food write automation", () => { + it.each([ + ["create", 120], + ["create-and-log", 300], + ["log", 180], + ])("budgets %s independently of login", async (operation, timeout) => { + const execute = vi + .fn() + .mockResolvedValueOnce({ success: true, result: { loggedIn: true } }) + .mockResolvedValueOnce({ success: true, result: { success: true } }); + const close = vi.fn(); + const client = createAutomationClient(async () => ({ execute, close })); + if (operation === "log") { + await client.logFood({ + name: "Gomez, Buffalo Chicken Turtle with Ranch", + meal: "Lunch", + }); + } else { + await client.addCustomFood({ + name: "Test Food", + protein: 60, + log: operation === "create-and-log" ? "Lunch" : undefined, + }); + } + expect(execute.mock.calls.map((call) => call[1])).toEqual([30, timeout]); + expect(close).toHaveBeenCalledOnce(); + }); + + it.each(["create", "log"])( + "does not replay %s after an ambiguous timeout", + async (operation) => { + const execute = vi + .fn() + .mockResolvedValueOnce({ success: true, result: { loggedIn: true } }) + .mockResolvedValueOnce({ + success: false, + error: "The operation was aborted due to timeout", + }); + const close = vi.fn(); + const client = createAutomationClient(async () => ({ execute, close })); + const result = + operation === "create" + ? client.addCustomFood({ + name: "Test Food", + protein: 60, + log: "Lunch", + }) + : client.logFood({ name: "Test Food", meal: "Lunch" }); + await expect(result).rejects.toThrow("aborted due to timeout"); + expect(execute).toHaveBeenCalledTimes(2); + expect(close).toHaveBeenCalledOnce(); + } + ); +}); diff --git a/tests/cronometer/parse.test.ts b/tests/cronometer/parse.test.ts index 057a2fe..eebd302 100644 --- a/tests/cronometer/parse.test.ts +++ b/tests/cronometer/parse.test.ts @@ -140,6 +140,29 @@ describe("parseBiometrics", () => { }); describe("parseServings", () => { + it("does not invent zero nutrients when the live export omits them", () => { + const [entry] = parseServings( + 'Day,Time,Group,Food Name,Amount,Category\n2026-09-10,12:46 PM,Lunch,"Gomez, Buffalo Chicken Turtle with Ranch",1.00 serving,Custom' + ); + expect(entry).toMatchObject({ + calories: null, + protein: null, + carbs: null, + fat: null, + }); + }); + + it("distinguishes genuine zero from missing or invalid nutrient values", () => { + const [entry] = parseServings( + "Day,Group,Food Name,Amount,Energy (kcal),Protein (g),Carbs (g),Fat (g)\n2026-09-10,Lunch,Test,1 serving,0,,invalid,Infinity" + ); + expect(entry).toMatchObject({ + calories: 0, + protein: null, + carbs: null, + fat: null, + }); + }); const sampleCSV = `Day,Time,Group,Food Name,Amount,Energy (kcal),Protein (g),Carbs (g),Fat (g),Fiber (g),Sodium (mg),Category 2026-02-11,07:30 PM,Dinner,"Beef Steak, Tenderloin",150.00 g,306,46.01,0,13.5,0,61.5,"Beef Products" 2026-02-11,12:30 PM,Lunch,"Cabbage, Raw",95.00 g,26.6,0.91,6.06,0.04,2.13,15.2,"Vegetables and Vegetable Products"`; diff --git a/tests/export-output.test.ts b/tests/export-output.test.ts index a3d0339..2c1ba25 100644 --- a/tests/export-output.test.ts +++ b/tests/export-output.test.ts @@ -32,6 +32,20 @@ describe("servings export output", () => { ); }); + it("reports omitted nutrition as null in machine-readable output", async () => { + vi.mocked(exportData).mockResolvedValue( + "Day,Group,Food Name,Amount\n2026-09-10,Lunch,Gomez,1 serving" + ); + await exportCmd("servings", { date: "2026-09-10", json: true }); + const output = JSON.parse(vi.mocked(console.log).mock.calls[0][0]); + expect(output[0]).toMatchObject({ + calories: null, + protein: null, + carbs: null, + fat: null, + }); + }); + it("emits an explicit array when a meal filter has no matches", async () => { vi.mocked(exportData).mockResolvedValue( 'Day,Group,Food Name,Amount\n2026-09-09,Lunch,"Quick Add, Protein",37 g' diff --git a/tests/kernel/food-write-waits.test.ts b/tests/kernel/food-write-waits.test.ts new file mode 100644 index 0000000..3885821 --- /dev/null +++ b/tests/kernel/food-write-waits.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildLogFoodCode } from "../../src/kernel/log-food.js"; +import { buildAddCustomFoodCode } from "../../src/kernel/add-custom-food.js"; + +describe("generated food write waits", () => { + it.each([ + buildLogFoodCode({ name: "Test Food", meal: "Lunch" }), + buildAddCustomFoodCode({ name: "Test Food", protein: 60, log: "Lunch" }), + ])("bounds actions and verifies diary dialog dismissal", (code) => { + expect(code).toContain("filter({ visible: true })"); + expect(code).toContain("click({ timeout: 3000 })"); + expect(code).toContain("fill('', { timeout: 3000 })"); + expect(code).not.toContain(".first().click();"); + expect(code).not.toContain("'text=\"SEARCH\")'"); + expect(code).toContain("state: 'hidden'"); + }); + + it("skips hidden controls and tries the next selector after a bounded action failure", async () => { + const click = vi + .fn() + .mockRejectedValueOnce(new Error("disabled")) + .mockResolvedValueOnce(undefined); + const filter = vi.fn().mockReturnThis(); + const locator = { + filter, + count: vi.fn().mockResolvedValue(1), + first: vi.fn(), + click, + }; + locator.first.mockReturnValue(locator); + const page = { + goto: vi.fn(), + waitForLoadState: vi.fn().mockResolvedValue(undefined), + url: () => "https://cronometer.com/#diary", + locator: vi.fn(() => locator), + }; + const code = buildLogFoodCode({ name: "Test Food" }); + const helperEnd = code.indexOf("// Helper: right-click"); + const execute = new Function( + "page", + `return (async () => { + ${code.slice(0, helperEnd)} + return clickFirst(['button:has-text("SEARCH")', 'text="SEARCH"'], 'search'); + })()` + ); + await expect(execute(page)).resolves.toBe(true); + expect(filter).toHaveBeenCalledWith({ visible: true }); + expect(click).toHaveBeenCalledTimes(2); + expect(click).toHaveBeenNthCalledWith(1, { timeout: 3000 }); + expect(click).toHaveBeenNthCalledWith(2, { timeout: 3000 }); + }); +});