Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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`
Expand Down
102 changes: 102 additions & 0 deletions src/ExerciseEntryParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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, "\\$&");
const regex = new RegExp(
`^#${escapedTag}\\s+(?<name>\\[\\[[^\\]]+\\]\\]|[^\\d\\r\\n]+?)\\s+(?:(?<weight>\\d+(?:\\.\\d+)?)(?<unit>kg|kgs?|lb|lbs?)?\\s+)?(?<sets>\\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 normalizedUnit = unit?.toLowerCase() ?? "";
return {
value,
unit: normalizedUnit.length > 0 ? normalizedUnit : "kg",
};
}
}
78 changes: 78 additions & 0 deletions src/ExerciseMetadataService.ts
Original file line number Diff line number Diff line change
@@ -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<string, number | null> = 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<string, unknown>): 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;
}
}
23 changes: 20 additions & 3 deletions src/FoodTrackerPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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);
};
Expand Down Expand Up @@ -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,
Expand All @@ -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") {
Expand Down
13 changes: 12 additions & 1 deletion src/FrontmatterTotalsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-";

Expand Down Expand Up @@ -77,16 +78,24 @@ export default class FrontmatterTotalsService {
private settingsService: SettingsService;
private goalsService: GoalsService;
private dailyNoteLocator: DailyNoteLocator;
private exerciseMetadataService: ExerciseMetadataService;
private pendingUpdates: Map<string, NodeJS.Timeout> = new Map();
private filesBeingWritten: Set<string> = 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 {
Expand Down Expand Up @@ -127,6 +136,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,
});
Expand Down
Loading