Skip to content

Commit fff7232

Browse files
authored
Merge pull request #5 from forketyfork/codex/add-customizable-goals-file-and-progress-bar
Add goals progress tracking
2 parents 53bce70 + 6c9c5f5 commit fff7232

15 files changed

+800
-72
lines changed

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ This file provides guidance to AI agents when working with code in this reposito
2020
## General guidelines
2121

2222
- IMPORTANT: After finishing your task, make sure to run `yarn build` and fix any introduced issues.
23+
- IMPORTANT: On finishing your task, make sure the README.md file is up to date with regards to the new features, usage, and development.
24+
- IMPORTANT: Always try to extract testable logic that can be independent of Obsidian plugins to separate classes or functions and write unit tests for it.
25+
- IMPORTANT: Do not write useless tests just to increase coverage, make them actually useful for catching issues in the code.
2326

2427
## Typescript & Testing
2528

DocumentTotalManager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export default class DocumentTotalManager {
2727
cls: "food-tracker-total",
2828
});
2929

30-
this.documentTotalElement.textContent = totalText;
30+
this.documentTotalElement.innerHTML = totalText;
3131

3232
// Append at the end of contentEl so it appears at the bottom
3333
contentEl.appendChild(this.documentTotalElement);
@@ -61,7 +61,7 @@ export default class DocumentTotalManager {
6161
}
6262

6363
if (this.documentTotalElement) {
64-
this.documentTotalElement.textContent = totalText;
64+
this.documentTotalElement.innerHTML = totalText;
6565
} else {
6666
this.show(totalText, view);
6767
}

FoodTrackerPlugin.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import NutrientCache from "./NutrientCache";
55
import FoodSuggest from "./FoodSuggest";
66
import NutritionTotal from "./NutritionTotal";
77
import FoodHighlightExtension from "./FoodHighlightExtension";
8+
import GoalsHighlightExtension from "./GoalsHighlightExtension";
89
import DocumentTotalManager from "./DocumentTotalManager";
910
import { SettingsService, FoodTrackerPluginSettings, DEFAULT_SETTINGS } from "./SettingsService";
11+
import GoalsService from "./GoalsService";
1012

1113
export default class FoodTrackerPlugin extends Plugin {
1214
settings: FoodTrackerPluginSettings;
@@ -16,7 +18,9 @@ export default class FoodTrackerPlugin extends Plugin {
1618
statusBarItem: HTMLElement;
1719
documentTotalManager: DocumentTotalManager;
1820
settingsService: SettingsService;
21+
goalsService: GoalsService;
1922
private foodHighlightExtension: FoodHighlightExtension;
23+
private goalsHighlightExtension: GoalsHighlightExtension;
2024

2125
async onload() {
2226
await this.loadSettings();
@@ -28,14 +32,18 @@ export default class FoodTrackerPlugin extends Plugin {
2832
this.settingsService = new SettingsService();
2933
this.settingsService.initialize(this.settings);
3034

35+
this.goalsService = new GoalsService(this.app, this.settings.goalsFile || "");
36+
// Delay goals loading until vault is ready
37+
this.app.workspace.onLayoutReady(() => this.goalsService.loadGoals());
38+
3139
// Register food autocomplete
3240
this.foodSuggest = new FoodSuggest(this.app, this.settingsService, this.nutrientCache);
3341
this.registerEditorSuggest(this.foodSuggest);
3442

3543
// Initialize nutrition total
3644
this.nutritionTotal = new NutritionTotal(this.nutrientCache);
3745
this.statusBarItem = this.addStatusBarItem();
38-
this.statusBarItem.setText("");
46+
this.statusBarItem.innerHTML = "";
3947

4048
// Initialize document total manager
4149
this.documentTotalManager = new DocumentTotalManager();
@@ -112,6 +120,15 @@ export default class FoodTrackerPlugin extends Plugin {
112120
})
113121
);
114122

123+
this.registerEvent(
124+
this.app.vault.on("modify", file => {
125+
if (file.path === this.settings.goalsFile || file.name === this.settings.goalsFile) {
126+
void this.goalsService.loadGoals();
127+
void this.updateNutritionTotal();
128+
}
129+
})
130+
);
131+
115132
// Update nutrition total when active file changes
116133
this.registerEvent(
117134
this.app.workspace.on("active-leaf-change", () => {
@@ -123,6 +140,10 @@ export default class FoodTrackerPlugin extends Plugin {
123140
this.foodHighlightExtension = new FoodHighlightExtension(this.settingsService);
124141
this.registerEditorExtension(this.foodHighlightExtension.createExtension());
125142

143+
// Register CodeMirror extension for goals highlighting
144+
this.goalsHighlightExtension = new GoalsHighlightExtension(this.settingsService);
145+
this.registerEditorExtension(this.goalsHighlightExtension.createExtension());
146+
126147
// Initial total update
127148
void this.updateNutritionTotal();
128149
}
@@ -132,6 +153,9 @@ export default class FoodTrackerPlugin extends Plugin {
132153
if (this.foodHighlightExtension) {
133154
this.foodHighlightExtension.destroy();
134155
}
156+
if (this.goalsHighlightExtension) {
157+
this.goalsHighlightExtension.destroy();
158+
}
135159
this.foodSuggest?.suggestionCore?.destroy();
136160
}
137161

@@ -150,6 +174,13 @@ export default class FoodTrackerPlugin extends Plugin {
150174
this.nutritionTotal = new NutritionTotal(this.nutrientCache);
151175
}
152176

177+
if (this.goalsService) {
178+
this.goalsService.setGoalsFile(this.settings.goalsFile || "");
179+
await this.goalsService.loadGoals();
180+
}
181+
182+
// Goals highlighting extension automatically updates via SettingsService subscription
183+
153184
// Update settings service
154185
if (this.settingsService) {
155186
this.settingsService.updateSettings(this.settings);
@@ -175,14 +206,15 @@ export default class FoodTrackerPlugin extends Plugin {
175206
const totalText = this.nutritionTotal.calculateTotalNutrients(
176207
content,
177208
this.settingsService.currentEscapedFoodTag,
178-
true
209+
true,
210+
this.goalsService.currentGoals
179211
);
180212

181213
if (this.settings.totalDisplayMode === "status-bar") {
182-
this.statusBarItem?.setText(totalText);
214+
if (this.statusBarItem) this.statusBarItem.innerHTML = totalText;
183215
this.documentTotalManager.remove();
184216
} else {
185-
this.statusBarItem?.setText("");
217+
if (this.statusBarItem) this.statusBarItem.innerHTML = "";
186218
this.documentTotalManager.show(totalText, activeView);
187219
}
188220
} catch (error) {
@@ -192,7 +224,7 @@ export default class FoodTrackerPlugin extends Plugin {
192224
}
193225

194226
private clearTotal(): void {
195-
this.statusBarItem?.setText("");
227+
if (this.statusBarItem) this.statusBarItem.innerHTML = "";
196228
this.documentTotalManager.remove();
197229
}
198230
}

FoodTrackerSettingTab.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,18 @@ export default class FoodTrackerSettingTab extends PluginSettingTab {
4949
await this.plugin.saveSettings();
5050
})
5151
);
52+
53+
new Setting(containerEl)
54+
.setName("Goals file")
55+
.setDesc("File containing daily nutrition goals")
56+
.addText(text =>
57+
text
58+
.setPlaceholder("nutrition-goals.md")
59+
.setValue(this.plugin.settings.goalsFile)
60+
.onChange(async value => {
61+
this.plugin.settings.goalsFile = value;
62+
await this.plugin.saveSettings();
63+
})
64+
);
5265
}
5366
}

GoalsHighlightCore.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export interface GoalsHighlightRange {
2+
start: number;
3+
end: number;
4+
type: "value";
5+
}
6+
7+
/**
8+
* Extracts highlight ranges from text content for goals files
9+
* This is a pure function with no Obsidian dependencies for easy testing
10+
*/
11+
export function extractGoalsHighlightRanges(text: string, lineStart: number): GoalsHighlightRange[] {
12+
const ranges: GoalsHighlightRange[] = [];
13+
14+
// Match pattern: "key: value" where value is a number (with optional decimal)
15+
const goalsRegex = /^(\w+):\s*(\d+(?:\.\d+)?)/;
16+
const match = goalsRegex.exec(text);
17+
18+
if (match?.index !== undefined) {
19+
const valueText = match[2];
20+
const valueStart = lineStart + match.index + match[0].indexOf(valueText);
21+
const valueEnd = valueStart + valueText.length;
22+
23+
ranges.push({
24+
start: valueStart,
25+
end: valueEnd,
26+
type: "value",
27+
});
28+
}
29+
30+
return ranges;
31+
}
32+
33+
/**
34+
* Processes multiple lines of text and extracts all highlight ranges for goals
35+
*/
36+
export function extractMultilineGoalsHighlightRanges(text: string, startOffset: number): GoalsHighlightRange[] {
37+
const ranges: GoalsHighlightRange[] = [];
38+
const lines = text.split("\n");
39+
let lineStart = startOffset;
40+
41+
for (const line of lines) {
42+
const lineRanges = extractGoalsHighlightRanges(line, lineStart);
43+
ranges.push(...lineRanges);
44+
lineStart += line.length + 1; // +1 for newline
45+
}
46+
47+
return ranges;
48+
}

GoalsHighlightExtension.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { Extension } from "@codemirror/state";
2+
import { EditorView, ViewPlugin, Decoration, DecorationSet, ViewUpdate } from "@codemirror/view";
3+
import { RangeSetBuilder } from "@codemirror/state";
4+
import { extractMultilineGoalsHighlightRanges } from "./GoalsHighlightCore";
5+
import { SettingsService } from "./SettingsService";
6+
import { Subscription } from "rxjs";
7+
8+
/**
9+
* CodeMirror extension that highlights values in goals files
10+
* Provides visual feedback for nutrition goal values
11+
*/
12+
export default class GoalsHighlightExtension {
13+
private settingsService: SettingsService;
14+
private goalsFile: string = "";
15+
private subscription: Subscription;
16+
17+
constructor(settingsService: SettingsService) {
18+
this.settingsService = settingsService;
19+
20+
// Subscribe to goals file changes
21+
this.subscription = this.settingsService.goalsFile$.subscribe(goalsFile => {
22+
this.goalsFile = goalsFile;
23+
});
24+
}
25+
26+
/**
27+
* Clean up subscriptions when the extension is destroyed
28+
*/
29+
destroy(): void {
30+
if (this.subscription) {
31+
this.subscription.unsubscribe();
32+
}
33+
}
34+
35+
createExtension(): Extension {
36+
const goalsValueDecoration = Decoration.mark({
37+
class: "goals-value",
38+
});
39+
40+
const getGoalsFile = () => this.goalsFile;
41+
42+
const goalsHighlightPlugin = ViewPlugin.fromClass(
43+
class {
44+
decorations: DecorationSet;
45+
46+
constructor(view: EditorView) {
47+
this.decorations = this.buildDecorations(view);
48+
}
49+
50+
update(update: ViewUpdate) {
51+
// Always rebuild decorations when document changes or viewport changes
52+
// This ensures highlighting updates when switching files
53+
if (update.docChanged || update.viewportChanged) {
54+
this.decorations = this.buildDecorations(update.view);
55+
}
56+
}
57+
58+
/**
59+
* Scans visible text for goals entries and creates decorations for highlighting
60+
* Uses a simple heuristic to detect if we're in a goals file
61+
*/
62+
buildDecorations(view: EditorView): DecorationSet {
63+
const builder = new RangeSetBuilder<Decoration>();
64+
65+
const goalsFile = getGoalsFile();
66+
if (!goalsFile) {
67+
return builder.finish();
68+
}
69+
70+
// Get the full document text to check if it looks like a goals file
71+
const docText = view.state.doc.toString();
72+
73+
// Simple heuristic: if the document contains goal-like patterns, highlight it
74+
// This is more reliable than trying to get the file path from CodeMirror
75+
const goalPatterns = /^(calories|fats|protein|carbs|fiber|sugar|sodium):\s*\d+/m;
76+
if (!goalPatterns.test(docText)) {
77+
return builder.finish();
78+
}
79+
80+
for (let { from, to } of view.visibleRanges) {
81+
const text = view.state.doc.sliceString(from, to);
82+
83+
// Extract highlight ranges using the pure function
84+
const ranges = extractMultilineGoalsHighlightRanges(text, from);
85+
86+
// Convert ranges to CodeMirror decorations
87+
for (const range of ranges) {
88+
builder.add(range.start, range.end, goalsValueDecoration);
89+
}
90+
}
91+
92+
return builder.finish();
93+
}
94+
},
95+
{
96+
decorations: v => v.decorations,
97+
}
98+
);
99+
100+
return goalsHighlightPlugin;
101+
}
102+
}

GoalsService.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { App, TFile } from "obsidian";
2+
3+
export interface NutrientGoals {
4+
calories?: number;
5+
fats?: number;
6+
protein?: number;
7+
carbs?: number;
8+
fiber?: number;
9+
sugar?: number;
10+
sodium?: number;
11+
}
12+
13+
export default class GoalsService {
14+
private app: App;
15+
private goalsFile: string;
16+
private goals: NutrientGoals = {};
17+
18+
constructor(app: App, goalsFile: string) {
19+
this.app = app;
20+
this.goalsFile = goalsFile;
21+
}
22+
23+
setGoalsFile(path: string): void {
24+
this.goalsFile = path;
25+
}
26+
27+
get currentGoals(): NutrientGoals {
28+
return this.goals;
29+
}
30+
31+
async loadGoals(): Promise<void> {
32+
if (!this.goalsFile) {
33+
this.goals = {};
34+
return;
35+
}
36+
try {
37+
let file = this.app.vault.getAbstractFileByPath(this.goalsFile);
38+
if (file instanceof TFile) {
39+
const content = await this.app.vault.read(file);
40+
this.goals = this.parseGoals(content);
41+
} else {
42+
this.goals = {};
43+
}
44+
} catch (error) {
45+
console.error("Error loading goals file:", error);
46+
this.goals = {};
47+
}
48+
}
49+
50+
private parseGoals(content: string): NutrientGoals {
51+
const goals: NutrientGoals = {};
52+
const lines = content.split(/\r?\n/);
53+
for (const line of lines) {
54+
const match = line.match(/^(\w+):\s*(\d+(?:\.\d+)?)/);
55+
if (match) {
56+
const key = match[1].toLowerCase();
57+
const value = parseFloat(match[2]);
58+
switch (key) {
59+
case "calories":
60+
case "fats":
61+
case "protein":
62+
case "carbs":
63+
case "fiber":
64+
case "sugar":
65+
case "sodium":
66+
(goals as Record<string, number>)[key] = value;
67+
break;
68+
}
69+
}
70+
}
71+
return goals;
72+
}
73+
}

0 commit comments

Comments
 (0)