Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ crono export <type> [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) |

Expand All @@ -409,6 +409,12 @@ crono export <type> [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
Expand All @@ -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
Expand Down Expand Up @@ -476,6 +482,18 @@ export CRONO_GWT_HEADER=<new-value>

## 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
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions src/automation/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"}`
Expand All @@ -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"}`
Expand Down
7 changes: 5 additions & 2 deletions src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
20 changes: 15 additions & 5 deletions src/commands/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
9 changes: 7 additions & 2 deletions src/commands/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,13 @@ export async function log(name: string, options: LogOptions): Promise<void> {
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);
}
}
24 changes: 15 additions & 9 deletions src/cronometer/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 [];
Expand Down Expand Up @@ -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() ?? "",
};

Expand Down
5 changes: 3 additions & 2 deletions src/kernel/add-custom-food.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -195,6 +195,7 @@ export function buildAddCustomFoodCode(entry: CustomFoodEntry): string {
${buildFoodDialogCode({
errorPrefix: "Food created but ",
requireServingSize: false,
verifyDialogDismissed: true,
})}
}

Expand Down
17 changes: 9 additions & 8 deletions src/kernel/food-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
6 changes: 3 additions & 3 deletions src/kernel/log-food.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,17 @@ 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 {}
}
return false;
}

${buildFoodDialogCode({ updateServingSize: true })}
${buildFoodDialogCode({ updateServingSize: true, verifyDialogDismissed: true })}

return { success: true };
`;
Expand Down
55 changes: 55 additions & 0 deletions tests/automation/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
);
});
23 changes: 23 additions & 0 deletions tests/cronometer/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"`;
Expand Down
14 changes: 14 additions & 0 deletions tests/export-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading
Loading