diff --git a/README.md b/README.md index 71f7751..98ed576 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ This plugin works on **mobile** and **desktop**, with layouts that adapt to smal them from your totals - Autocomplete only suggests calorie values for workout entries (no food names or other nutrients) - Visual distinction: workout calories are highlighted in red to differentiate from food intake +- **Exercise notes (draft)**: Early parser support for structured exercise entries on the workout tag (`#workout`) capturing + movement names, working weight, set breakdowns, and calories via per-rep frontmatter metadata for future dashboards ### 📊 Real-time Nutrition Tracking @@ -179,6 +181,27 @@ In both cases, the plugin subtracts the logged calories (and any other specified **Calorie floor:** Your total calorie count is always floored at 0. If your workout calories exceed your food intake, the plugin displays 0 kcal instead of a negative value, ensuring totals never go below zero. +### Draft: Structured Exercise Notes + +An early parser is available for set-based workout logging alongside nutrition notes. It reuses the `#workout` tag and records the movement name, working weight, rep counts per set, and calories burned when your exercise note includes per-rep metadata. + +``` +#workout [[Pec Fly]] 40kg 15-15-15 +#workout Deadlift 120 5-5-5 +``` + +- Exercise names can be plain text or wikilinks. +- Weight units support `kg` or `lb` (omitted units default to kilograms). +- Sets are parsed from dash-separated rep counts and multiplied by the `kcal_per_rep` (or `calories_per_rep`) frontmatter value defined on the linked exercise note. For example: + + ```yaml + --- + kcal_per_rep: 3.2 + --- + ``` + +- Calories from structured workouts are subtracted from your totals the same way as explicit `#workout 200kcal` entries. + ### Setting Up Nutrition Goals 1. Create a goals file in your vault (e.g., `nutrition-goals.md`) @@ -215,7 +238,7 @@ Go to Settings > Food Tracker to configure: - **Nutrient directory**: Choose where nutrient files are stored (default: "nutrients"). The setting now offers type-ahead folder suggestions. - **Nutrition total display**: Choose to show the total in the status bar or directly in the document - **Food tag**: Customize the tag used for food entries (default: "food" for `#food`, can be changed to "meal" for `#meal`, "nutrition" for `#nutrition`, etc.) -- **Workout tag**: Customize the tag used for workout entries (default: "workout" for `#workout`) +- **Workout tag**: Customize the tag used for workout entries and structured exercise sets (default: "workout" for `#workout`) - **Daily note filename format**: Specify the Moment.js-style pattern used to find daily notes (default: `YYYY-MM-DD`). Supports tokens like `dddd` or literal text (e.g., `YYYY-MM-DD-[journal]`). The preview shows how today's note would be named. - **Daily note format examples**: - `YYYY.MM.DD` → `2025.11.12` diff --git a/src/ExerciseEntryParser.ts b/src/ExerciseEntryParser.ts new file mode 100644 index 0000000..bdbd8e3 --- /dev/null +++ b/src/ExerciseEntryParser.ts @@ -0,0 +1,120 @@ +import { SPECIAL_CHARS_REGEX } from "./constants"; + +export interface ExerciseEntry { + name: string; + sets: number[]; + weight?: { + value: number; + unit: string; + }; + lineNumber: number; + rawText: string; +} + +/** + * Lightweight parser for exercise entries in daily notes. + * + * Expected format: #workout [[Exercise Name]] 40kg 15-15-15 + */ +export default class ExerciseEntryParser { + private workoutTag: string; + + constructor(workoutTag: string) { + this.workoutTag = workoutTag.trim(); + } + + updateWorkoutTag(newTag: string): void { + this.workoutTag = newTag.trim(); + } + + parse(content: string): ExerciseEntry[] { + if (this.workoutTag.length === 0) { + return []; + } + + const escapedTag = this.workoutTag.replace(SPECIAL_CHARS_REGEX, "\\$&"); + /** + * Regex breakdown: + * ^#${escapedTag} - Match the workout tag at the start of the line + * \s+ - One or more spaces + * (? - Capture group "name": + * \[\[[^\]]+\]\] - Either a [[wikilink]] + * | - or + * [^\d\r\n]+? - any non-digit, non-newline text (exercise name) + * ) + * \s+ - One or more spaces + * (?: + * (?\d+(?:\.\d+)?) - Optional capture group "weight": integer or decimal number + * (?kg|kgs?|lb|lbs?)? - Optional capture group "unit": kg, kgs, lb, lbs + * \s+ - One or more spaces + * )? + * (?\d+(?:-\d+)*) - Capture group "sets": numbers separated by dashes (e.g., 10-10-8) + * (?=\s*$|\s+[^\s]) - Lookahead: end of line or more non-space text + */ + const regex = new RegExp( + `^#${escapedTag}\\s+(?\\[\\[[^\\]]+\\]\\]|[^\\d\\r\\n]+?)\\s+(?:(?\\d+(?:\\.\\d+)?)(?kg|kgs?|lb|lbs?)?\\s+)?(?\\d+(?:-\\d+)*)(?=\\s*$|\\s+[^\\s])`, + "i" + ); + + return content + .split(/\r?\n/) + .map((line, index) => this.parseLine(line.trim(), index + 1, regex)) + .filter((entry): entry is ExerciseEntry => entry !== null); + } + + private parseLine(line: string, lineNumber: number, regex: RegExp): ExerciseEntry | null { + if (line.length === 0) { + return null; + } + + const match = line.match(regex); + if (!match?.groups) { + return null; + } + + const sets = match.groups.sets + .split("-") + .map(value => Number.parseInt(value, 10)) + .filter(reps => !Number.isNaN(reps) && reps > 0); + + if (sets.length === 0) { + return null; + } + + const name = this.extractName(match.groups.name); + const weight = this.parseWeight(match.groups.weight, match.groups.unit); + + return { + name, + sets, + weight: weight ?? undefined, + lineNumber, + rawText: line, + }; + } + + private extractName(rawName: string): string { + const trimmedName = rawName.trim(); + if (trimmedName.startsWith("[[") && trimmedName.endsWith("]]")) { + return trimmedName.slice(2, -2).trim(); + } + return trimmedName; + } + + private parseWeight(rawWeight?: string, unit?: string): ExerciseEntry["weight"] | null { + if (!rawWeight) { + return null; + } + + const value = Number.parseFloat(rawWeight); + if (Number.isNaN(value) || value <= 0) { + return null; + } + + const lowercaseUnit = unit?.toLowerCase() ?? ""; + return { + value, + unit: lowercaseUnit.length > 0 ? lowercaseUnit : "kg", + }; + } +} diff --git a/src/ExerciseMetadataService.ts b/src/ExerciseMetadataService.ts new file mode 100644 index 0000000..c210431 --- /dev/null +++ b/src/ExerciseMetadataService.ts @@ -0,0 +1,78 @@ +import { App } from "obsidian"; + +const CALORIES_PER_REP_KEYS = ["kcal_per_rep", "calories_per_rep"]; + +export default class ExerciseMetadataService { + private app: App; + private cache: Map = new Map(); + + constructor(app: App) { + this.app = app; + } + + getCaloriesPerRep(exerciseName: string, sourcePath?: string): number | null { + const normalizedName = exerciseName.trim(); + if (normalizedName.length === 0) { + return null; + } + + const cacheKey = `${normalizedName}::${sourcePath ?? ""}`; + if (this.cache.has(cacheKey)) { + return this.cache.get(cacheKey) ?? null; + } + + try { + const file = this.app.metadataCache.getFirstLinkpathDest(normalizedName, sourcePath ?? ""); + if (!file) { + this.cache.set(cacheKey, null); + return null; + } + + const frontmatter = this.app.metadataCache.getFileCache(file)?.frontmatter; + if (!frontmatter) { + this.cache.set(cacheKey, null); + return null; + } + + const caloriesPerRep = this.extractCaloriesPerRep(frontmatter); + this.cache.set(cacheKey, caloriesPerRep); + return caloriesPerRep; + } catch (error) { + console.error(`Error resolving exercise calories for ${normalizedName}:`, error); + this.cache.set(cacheKey, null); + return null; + } + } + + clear(): void { + this.cache.clear(); + } + + private extractCaloriesPerRep(frontmatter: Record): number | null { + for (const key of CALORIES_PER_REP_KEYS) { + const value = frontmatter[key]; + const parsed = this.parseCaloriesPerRep(value); + if (parsed !== null) { + return parsed; + } + } + return null; + } + + private parseCaloriesPerRep(value: unknown): number | null { + if (value === null || value === undefined) { + return null; + } + + const numericValue = + typeof value === "number" ? value : typeof value === "string" ? Number.parseFloat(value) : null; + + if (numericValue === null) { + return null; + } + if (Number.isNaN(numericValue) || numericValue <= 0) { + return null; + } + return numericValue; + } +} diff --git a/src/FoodTrackerPlugin.ts b/src/FoodTrackerPlugin.ts index aa2aba6..86ee026 100644 --- a/src/FoodTrackerPlugin.ts +++ b/src/FoodTrackerPlugin.ts @@ -2,6 +2,7 @@ import { Plugin, MarkdownView, TFile, addIcon, Platform } from "obsidian"; import FoodTrackerSettingTab from "./FoodTrackerSettingTab"; import NutrientModal from "./NutrientModal"; import NutrientCache from "./NutrientCache"; +import ExerciseMetadataService from "./ExerciseMetadataService"; import FoodSuggest from "./FoodSuggest"; import NutritionTotal from "./NutritionTotal"; import FoodHighlightExtension from "./FoodHighlightExtension"; @@ -24,6 +25,7 @@ export default class FoodTrackerPlugin extends Plugin { documentTotalManager: DocumentTotalManager; settingsService: SettingsService; goalsService: GoalsService; + exerciseMetadataService: ExerciseMetadataService; private statsService: StatsService; private frontmatterTotalsService: FrontmatterTotalsService; private foodHighlightExtension: FoodHighlightExtension; @@ -49,6 +51,9 @@ export default class FoodTrackerPlugin extends Plugin { this.nutrientCache = new NutrientCache(this.app, this.settings.nutrientDirectory); this.nutrientCache.initialize(); + // Initialize exercise metadata resolver + this.exerciseMetadataService = new ExerciseMetadataService(this.app); + // Initialize settings service this.settingsService = new SettingsService(); this.settingsService.initialize(this.settings); @@ -90,7 +95,8 @@ export default class FoodTrackerPlugin extends Plugin { this.app, this.nutrientCache, this.settingsService, - this.goalsService + this.goalsService, + this.exerciseMetadataService ); // Initialize stats service @@ -99,7 +105,8 @@ export default class FoodTrackerPlugin extends Plugin { this.nutritionTotal, this.settingsService, this.goalsService, - this.nutrientCache + this.nutrientCache, + this.exerciseMetadataService ); // Add ribbon button for statistics @@ -186,8 +193,15 @@ export default class FoodTrackerPlugin extends Plugin { }) ); + this.registerEvent( + this.app.metadataCache.on("changed", () => { + this.exerciseMetadataService.clear(); + }) + ); + const onResolved = () => { this.nutrientCache.refresh(); + this.exerciseMetadataService.clear(); void this.updateNutritionTotal(); this.app.metadataCache.off("resolved", onResolved); }; @@ -317,6 +331,8 @@ export default class FoodTrackerPlugin extends Plugin { return; } + const sourcePath = activeView.file.path; + const content = await this.app.vault.cachedRead(activeView.file); const totalElement = this.nutritionTotal.calculateTotalNutrients( content, @@ -325,7 +341,8 @@ export default class FoodTrackerPlugin extends Plugin { this.goalsService.currentGoals, this.settingsService.currentEscapedWorkoutTag, true, - true + true, + exerciseName => this.exerciseMetadataService.getCaloriesPerRep(exerciseName, sourcePath) ); if (this.settings.totalDisplayMode === "status-bar") { diff --git a/src/FrontmatterTotalsService.ts b/src/FrontmatterTotalsService.ts index 8d73e45..60a835f 100644 --- a/src/FrontmatterTotalsService.ts +++ b/src/FrontmatterTotalsService.ts @@ -4,6 +4,7 @@ import NutrientCache from "./NutrientCache"; import { SettingsService } from "./SettingsService"; import GoalsService from "./GoalsService"; import DailyNoteLocator from "./DailyNoteLocator"; +import ExerciseMetadataService from "./ExerciseMetadataService"; export const FRONTMATTER_PREFIX = "ft-"; @@ -106,16 +107,24 @@ export default class FrontmatterTotalsService { private settingsService: SettingsService; private goalsService: GoalsService; private dailyNoteLocator: DailyNoteLocator; + private exerciseMetadataService: ExerciseMetadataService; private pendingUpdates: Map = new Map(); private filesBeingWritten: Set = new Set(); private debounceMs = 500; - constructor(app: App, nutrientCache: NutrientCache, settingsService: SettingsService, goalsService: GoalsService) { + constructor( + app: App, + nutrientCache: NutrientCache, + settingsService: SettingsService, + goalsService: GoalsService, + exerciseMetadataService: ExerciseMetadataService + ) { this.app = app; this.nutrientCache = nutrientCache; this.settingsService = settingsService; this.goalsService = goalsService; this.dailyNoteLocator = new DailyNoteLocator(settingsService); + this.exerciseMetadataService = exerciseMetadataService; } isDailyNote(file: TFile): boolean { @@ -156,6 +165,8 @@ export default class FrontmatterTotalsService { escapedFoodTag: true, workoutTag: this.settingsService.currentEscapedWorkoutTag, workoutTagEscaped: true, + getExerciseCaloriesPerRep: exerciseName => + this.exerciseMetadataService.getCaloriesPerRep(exerciseName, file.path), getNutritionData: (filename: string) => this.nutrientCache.getNutritionData(filename), goals: this.goalsService.currentGoals, }); diff --git a/src/NutritionCalculator.ts b/src/NutritionCalculator.ts index 5dab8b7..eee8bf7 100644 --- a/src/NutritionCalculator.ts +++ b/src/NutritionCalculator.ts @@ -1,4 +1,5 @@ import { SPECIAL_CHARS_REGEX, createInlineNutritionRegex, createLinkedFoodRegex, getUnitMultiplier } from "./constants"; +import ExerciseEntryParser, { ExerciseEntry } from "./ExerciseEntryParser"; export interface NutrientData { calories?: number; @@ -61,6 +62,7 @@ export interface NutritionCalculationParams { escapedFoodTag?: boolean; workoutTag?: string; workoutTagEscaped?: boolean; + getExerciseCaloriesPerRep?: (exerciseName: string) => number | null; getNutritionData: (filename: string) => NutrientData | null; onReadError?: (filename: string, error: unknown) => void; goals?: NutrientGoals; @@ -73,6 +75,7 @@ export function calculateNutritionTotals(params: NutritionCalculationParams): Nu escapedFoodTag = false, workoutTag = "workout", workoutTagEscaped, + getExerciseCaloriesPerRep, getNutritionData, onReadError, goals, @@ -99,9 +102,16 @@ export function calculateNutritionTotals(params: NutritionCalculationParams): Nu const foodEntries = parseFoodEntries(safeContent, escapedFood); const inlineEntries = parseInlineNutrientEntries(safeContent, escapedFood); + const exerciseEntries = + hasWorkoutTag && escapedWorkout ? new ExerciseEntryParser(trimmedWorkoutTag).parse(safeContent) : []; + const workoutSets = + exerciseEntries.length > 0 + ? convertExerciseEntriesToWorkoutEntries(exerciseEntries, getExerciseCaloriesPerRep) + : []; const workoutEntries = escapedWorkout ? parseInlineNutrientEntries(safeContent, escapedWorkout) : []; + const combinedWorkoutEntries = [...workoutEntries, ...workoutSets]; - if (foodEntries.length === 0 && inlineEntries.length === 0 && workoutEntries.length === 0) { + if (foodEntries.length === 0 && inlineEntries.length === 0 && combinedWorkoutEntries.length === 0) { return null; } @@ -109,8 +119,8 @@ export function calculateNutritionTotals(params: NutritionCalculationParams): Nu const inlineTotals = calculateInlineTotals(inlineEntries); let workoutTotals: NutrientData = {}; - if (workoutEntries.length > 0) { - const validWorkoutEntries = filterValidWorkoutEntries(workoutEntries); + if (combinedWorkoutEntries.length > 0) { + const validWorkoutEntries = filterValidWorkoutEntries(combinedWorkoutEntries); if (validWorkoutEntries.length > 0) { workoutTotals = calculateInlineTotals(validWorkoutEntries); if (Object.keys(workoutTotals).length > 0) { @@ -184,6 +194,61 @@ function parseInlineNutrientEntries(content: string, escapedFoodTag: string): In return entries; } +function convertExerciseEntriesToWorkoutEntries( + exerciseEntries: ExerciseEntry[], + resolveCaloriesPerRep?: (exerciseName: string) => number | null +): InlineNutrientEntry[] { + if (!resolveCaloriesPerRep) { + return []; + } + + const cache = new Map(); + const entries: InlineNutrientEntry[] = []; + + for (const entry of exerciseEntries) { + const totalReps = entry.sets.reduce((sum, reps) => sum + reps, 0); + if (totalReps <= 0) { + continue; + } + + const normalizedName = entry.name.trim(); + if (normalizedName.length === 0) { + continue; + } + + let caloriesPerRep = cache.get(normalizedName); + if (caloriesPerRep === undefined) { + caloriesPerRep = safeResolveCaloriesPerRep(resolveCaloriesPerRep, normalizedName); + cache.set(normalizedName, caloriesPerRep ?? null); + } + + if (caloriesPerRep === null) { + continue; + } + + const totalCalories = caloriesPerRep * totalReps; + if (!Number.isFinite(totalCalories) || totalCalories <= 0) { + continue; + } + + entries.push({ calories: totalCalories }); + } + + return entries; +} + +function safeResolveCaloriesPerRep( + resolveCaloriesPerRep: (exerciseName: string) => number | null, + exerciseName: string +): number | null { + try { + return resolveCaloriesPerRep(exerciseName); + } catch (error) { + console.error(`Error calculating calories for exercise ${exerciseName}:`, error); + return null; + } +} + function parseNutrientString(nutrientString: string): InlineNutrientEntry { const nutrientData: InlineNutrientEntry = {}; const nutrientRegex = /(-?\d+(?:\.\d+)?)\s*(kcal|fat|satfat|prot|carbs|sugar|fiber|sodium)/gi; diff --git a/src/NutritionTotal.ts b/src/NutritionTotal.ts index 81ce25c..238abb2 100644 --- a/src/NutritionTotal.ts +++ b/src/NutritionTotal.ts @@ -67,7 +67,8 @@ export default class NutritionTotal { goals?: NutrientGoals, workoutTag: string = "workout", workoutTagEscaped?: boolean, - showIcon: boolean = true + showIcon: boolean = true, + getExerciseCaloriesPerRep?: (exerciseName: string) => number | null ): HTMLElement | null { try { const result = calculateNutritionTotals({ @@ -76,6 +77,7 @@ export default class NutritionTotal { escapedFoodTag: escaped, workoutTag, workoutTagEscaped, + getExerciseCaloriesPerRep, getNutritionData: (filename: string) => this.nutrientCache.getNutritionData(filename), goals, }); diff --git a/src/SettingsService.ts b/src/SettingsService.ts index 51a29a9..b513231 100644 --- a/src/SettingsService.ts +++ b/src/SettingsService.ts @@ -3,20 +3,20 @@ import { map } from "rxjs/operators"; import { SPECIAL_CHARS_REGEX } from "./constants"; export interface FoodTrackerPluginSettings { - nutrientDirectory: string; - totalDisplayMode: "status-bar" | "document"; foodTag: string; workoutTag: string; + nutrientDirectory: string; + totalDisplayMode: "status-bar" | "document"; goalsFile: string; showCalorieHints: boolean; dailyNoteFormat: string; } export const DEFAULT_SETTINGS: FoodTrackerPluginSettings = { - nutrientDirectory: "nutrients", - totalDisplayMode: "status-bar", foodTag: "food", workoutTag: "workout", + nutrientDirectory: "nutrients", + totalDisplayMode: "status-bar", goalsFile: "nutrition-goals.md", showCalorieHints: true, dailyNoteFormat: "YYYY-MM-DD", @@ -135,40 +135,12 @@ export class SettingsService { } /** - * Get the current nutrient directory value synchronously - */ - get currentNutrientDirectory(): string { - return this.currentSettings.nutrientDirectory; - } - - /** - * Get the current goals file path synchronously - */ - get currentGoalsFile(): string { - return this.currentSettings.goalsFile; - } - - /** - * Get the current daily note filename format synchronously + * Get the current daily note format synchronously */ get currentDailyNoteFormat(): string { return this.currentSettings.dailyNoteFormat; } - /** - * Get the current total display mode value synchronously - */ - get currentTotalDisplayMode(): "status-bar" | "document" { - return this.currentSettings.totalDisplayMode; - } - - /** - * Get the current show calorie hints value synchronously - */ - get currentShowCalorieHints(): boolean { - return this.currentSettings.showCalorieHints; - } - /** * Updates all settings and notifies all subscribers */ diff --git a/src/StatsService.ts b/src/StatsService.ts index 2904aab..aa1f731 100644 --- a/src/StatsService.ts +++ b/src/StatsService.ts @@ -3,6 +3,7 @@ import NutritionTotal from "./NutritionTotal"; import { SettingsService } from "./SettingsService"; import GoalsService from "./GoalsService"; import DailyNoteLocator from "./DailyNoteLocator"; +import ExerciseMetadataService from "./ExerciseMetadataService"; import { extractFrontmatterTotals, FRONTMATTER_KEYS, @@ -52,13 +53,15 @@ export default class StatsService { private goalsService: GoalsService; private dailyNoteLocator: DailyNoteLocator; private nutrientCache: NutrientCache; + private exerciseMetadataService: ExerciseMetadataService; constructor( app: App, nutritionTotal: NutritionTotal, settingsService: SettingsService, goalsService: GoalsService, - nutrientCache: NutrientCache + nutrientCache: NutrientCache, + exerciseMetadataService: ExerciseMetadataService ) { this.app = app; this.nutritionTotal = nutritionTotal; @@ -66,6 +69,7 @@ export default class StatsService { this.goalsService = goalsService; this.dailyNoteLocator = new DailyNoteLocator(settingsService); this.nutrientCache = nutrientCache; + this.exerciseMetadataService = exerciseMetadataService; } async getMonthlyStats(year: number, month: number): Promise { @@ -157,6 +161,8 @@ export default class StatsService { escapedFoodTag: true, workoutTag: this.settingsService.currentEscapedWorkoutTag, workoutTagEscaped: true, + getExerciseCaloriesPerRep: exerciseName => + this.exerciseMetadataService.getCaloriesPerRep(exerciseName, file.path), getNutritionData: (filename: string) => this.nutrientCache.getNutritionData(filename), goals: this.goalsService.currentGoals, }); diff --git a/src/__tests__/ExerciseEntryParser.test.ts b/src/__tests__/ExerciseEntryParser.test.ts new file mode 100644 index 0000000..9d6a9d6 --- /dev/null +++ b/src/__tests__/ExerciseEntryParser.test.ts @@ -0,0 +1,40 @@ +import ExerciseEntryParser from "../ExerciseEntryParser"; + +describe("ExerciseEntryParser", () => { + test("parses workout entries with wikilinks and weight", () => { + const parser = new ExerciseEntryParser("workout"); + const entries = parser.parse("#workout [[Pec Fly]] 40kg 15-15-15"); + + expect(entries).toHaveLength(1); + expect(entries[0]).toEqual({ + name: "Pec Fly", + sets: [15, 15, 15], + weight: { value: 40, unit: "kg" }, + lineNumber: 1, + rawText: "#workout [[Pec Fly]] 40kg 15-15-15", + }); + }); + + test("supports plain names and falls back to kg when unit missing", () => { + const parser = new ExerciseEntryParser("workout"); + const entries = parser.parse("#workout Deadlift 120 5-5-5"); + + expect(entries[0].name).toBe("Deadlift"); + expect(entries[0].weight).toEqual({ value: 120, unit: "kg" }); + expect(entries[0].sets).toEqual([5, 5, 5]); + }); + + test("ignores entries without valid sets", () => { + const parser = new ExerciseEntryParser("workout"); + const entries = parser.parse("#workout Running 30kg reps"); + + expect(entries).toHaveLength(0); + }); + + test("allows custom workout tag", () => { + const parser = new ExerciseEntryParser("lift"); + const entries = parser.parse("#lift [[Bench Press]] 80kg 8-8-6"); + + expect(entries[0].name).toBe("Bench Press"); + }); +}); diff --git a/src/__tests__/NutritionCalculator.test.ts b/src/__tests__/NutritionCalculator.test.ts index 08c6cab..f11c6f0 100644 --- a/src/__tests__/NutritionCalculator.test.ts +++ b/src/__tests__/NutritionCalculator.test.ts @@ -284,4 +284,30 @@ describe("calculateNutritionTotals", () => { percentRemaining: 0, }); }); + + test("derives workout calories from set-based entries with per-rep metadata", () => { + const result = calculateNutritionTotals( + buildParams({ + content: "#workout [[Pec Fly]] 40kg 15-15-10\n#food Lunch 300kcal", + getNutritionData: () => null, + getExerciseCaloriesPerRep: exerciseName => (exerciseName === "Pec Fly" ? 3 : null), + }) + ); + + expect(result).not.toBeNull(); + expect(result?.workoutTotals.calories).toBeCloseTo(120); // (15+15+10) reps * 3 kcal + expect(result?.inlineTotals.calories).toBeCloseTo(180); // 300 - 120 + }); + + test("ignores set-based workouts when no per-rep metadata is available", () => { + const result = calculateNutritionTotals( + buildParams({ + content: "#workout [[Unknown Move]] 10-10-10\n#food Snack 150kcal", + getNutritionData: () => null, + }) + ); + + expect(result?.workoutTotals.calories).toBeUndefined(); + expect(result?.inlineTotals.calories).toBeCloseTo(150); + }); }); diff --git a/src/__tests__/StatsService.test.ts b/src/__tests__/StatsService.test.ts index a4e65e3..df32e06 100644 --- a/src/__tests__/StatsService.test.ts +++ b/src/__tests__/StatsService.test.ts @@ -4,6 +4,7 @@ import { App, TFile, CachedMetadata } from "obsidian"; import { SettingsService, DEFAULT_SETTINGS } from "../SettingsService"; import GoalsService from "../GoalsService"; import NutrientCache from "../NutrientCache"; +import ExerciseMetadataService from "../ExerciseMetadataService"; function createEl( tag: T, @@ -33,6 +34,7 @@ g.document.createElementNS = jest const goalsService = { currentGoals: {} } as unknown as GoalsService; const dummyCache = { getNutritionData: () => null } as unknown as NutrientCache; +const exerciseMetadataService = { getCaloriesPerRep: () => null } as unknown as ExerciseMetadataService; interface FrontmatterData { "ft-calories"?: number; @@ -98,7 +100,7 @@ describe("StatsService", () => { const settings = new SettingsService(); settings.initialize(DEFAULT_SETTINGS); const nutritionTotal = new NutritionTotal(dummyCache); - const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache); + const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache, exerciseMetadataService); const stats = await service.getMonthlyStats(2024, 8); expect(stats.length).toBe(31); @@ -124,7 +126,7 @@ describe("StatsService", () => { dailyNoteFormat: "YYYY.MM.DD", }); const nutritionTotal = new NutritionTotal(dummyCache); - const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache); + const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache, exerciseMetadataService); const stats = await service.getMonthlyStats(2024, 8); const dayStat = stats.find(s => s.date === "2024-08-01"); @@ -140,7 +142,7 @@ describe("StatsService", () => { const settings = new SettingsService(); settings.initialize(DEFAULT_SETTINGS); const nutritionTotal = new NutritionTotal(dummyCache); - const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache); + const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache, exerciseMetadataService); const stats = await service.getMonthlyStats(2024, 8); const dayStat = stats.find(s => s.date === "2024-08-01"); @@ -158,7 +160,7 @@ describe("StatsService", () => { const settings = new SettingsService(); settings.initialize(DEFAULT_SETTINGS); const nutritionTotal = new NutritionTotal(dummyCache); - const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache); + const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache, exerciseMetadataService); const stats = await service.getMonthlyStats(2024, 8); @@ -180,7 +182,7 @@ describe("StatsService", () => { const settings = new SettingsService(); settings.initialize(DEFAULT_SETTINGS); const nutritionTotal = new NutritionTotal(dummyCache); - const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache); + const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache, exerciseMetadataService); const stats = await service.getMonthlyStats(2024, 8); @@ -202,7 +204,7 @@ describe("StatsService", () => { const settings = new SettingsService(); settings.initialize(DEFAULT_SETTINGS); const nutritionTotal = new NutritionTotal(dummyCache); - const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache); + const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache, exerciseMetadataService); await service.getMonthlyStats(2024, 8); @@ -220,7 +222,7 @@ describe("StatsService", () => { const settings = new SettingsService(); settings.initialize(DEFAULT_SETTINGS); const nutritionTotal = new NutritionTotal(dummyCache); - const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache); + const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache, exerciseMetadataService); const stats = await service.getMonthlyStats(2024, 8); @@ -246,7 +248,7 @@ describe("StatsService", () => { const settings = new SettingsService(); settings.initialize(DEFAULT_SETTINGS); const nutritionTotal = new NutritionTotal(dummyCache); - const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache); + const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache, exerciseMetadataService); const stats = await service.getMonthlyStats(2024, 8); const dayStat = stats.find(s => s.date === "2024-08-01"); @@ -270,7 +272,7 @@ describe("StatsService", () => { const settings = new SettingsService(); settings.initialize(DEFAULT_SETTINGS); const nutritionTotal = new NutritionTotal(dummyCache); - const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache); + const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache, exerciseMetadataService); const stats = await service.getMonthlyStats(2024, 8); const dayStat = stats.find(s => s.date === "2024-08-01"); @@ -285,7 +287,7 @@ describe("StatsService", () => { const settings = new SettingsService(); settings.initialize(DEFAULT_SETTINGS); const nutritionTotal = new NutritionTotal(dummyCache); - const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache); + const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache, exerciseMetadataService); const stats = await service.getMonthlyStats(2024, 8); const dayStat = stats.find(s => s.date === "2024-08-01"); @@ -304,7 +306,7 @@ describe("StatsService", () => { const settings = new SettingsService(); settings.initialize(DEFAULT_SETTINGS); const nutritionTotal = new NutritionTotal(dummyCache); - const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache); + const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache, exerciseMetadataService); const stats = await service.getMonthlyStats(2024, 8); const dayStat = stats.find(s => s.date === "2024-08-01"); @@ -321,7 +323,7 @@ describe("StatsService", () => { const settings = new SettingsService(); settings.initialize(DEFAULT_SETTINGS); const nutritionTotal = new NutritionTotal(dummyCache); - const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache); + const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache, exerciseMetadataService); const stats = await service.getMonthlyStats(2024, 8); const dayStat = stats.find(s => s.date === "2024-08-01"); @@ -342,7 +344,7 @@ describe("StatsService", () => { const settings = new SettingsService(); settings.initialize(DEFAULT_SETTINGS); const nutritionTotal = new NutritionTotal(dummyCache); - const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache); + const service = new StatsService(app, nutritionTotal, settings, goalsService, dummyCache, exerciseMetadataService); const stats = await service.getMonthlyStats(2024, 8); const dayStat = stats.find(s => s.date === "2024-08-01");