foam-habits 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # foam-habits
2
+
3
+ A terminal habit tracker that reads from [Foam](https://foambubble.github.io/foam/) daily notes and displays a GitHub-style heatmap.
4
+
5
+ ```
6
+ Foam Habits / Last 28 Days
7
+
8
+ Dec Jan
9
+ Habit 05 12 19 25 01
10
+
11
+ 💪 Gym ░░░░░█░░░░░░░░░░░░░░░░░░░░░█ (1 day)
12
+ 💧 Drink water ░░░░░░░░░░░░░░░░░░░░░░░░░░░▓ (0 days)
13
+ 📖 Study ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ (0 days)
14
+ 🧘 Meditation ░░░░░░░░░░░░░░░░░░░░░░░░░░░█ (1 day)
15
+ 🤸 Mobility ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ (0 days)
16
+ ```
17
+
18
+ ## Prerequisites
19
+
20
+ - Node.js >= 16
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ # Run without installing
26
+ npx foam-habits
27
+
28
+ # Or install globally
29
+ npm install -g foam-habits
30
+ ```
31
+
32
+ ## Configuration
33
+
34
+ Create a `.foam/habits.yaml` file in your Foam workspace root:
35
+
36
+ ```yaml
37
+ habits:
38
+ Gym:
39
+ emoji: 💪
40
+
41
+ Drink water:
42
+ emoji: 💧
43
+ goal: 4L
44
+
45
+ Study:
46
+ emoji: 📖
47
+ goal: 30min
48
+ threshold: 0.8 # Consider done at 80% (24min)
49
+
50
+ Meditation:
51
+ emoji: 🧘
52
+
53
+ Mobility:
54
+ emoji: 🤸
55
+ ```
56
+
57
+ ### Habit Options
58
+
59
+ | Option | Type | Default | Description |
60
+ | ----------- | ------ | -------- | ----------------------------------------------------------- |
61
+ | `emoji` | string | required | Emoji displayed next to habit name |
62
+ | `goal` | string | - | Target value with unit (e.g., `"4L"`, `"30min"`, `"2.5km"`) |
63
+ | `threshold` | number | `1.0` | Percentage (0.0-1.0) of goal to consider habit "done" |
64
+
65
+ **Boolean habits** (no goal): Present = complete. Just log the habit name.
66
+
67
+ **Quantitative habits** (with goal): Track progress toward a target. The value is compared against the goal.
68
+
69
+ ## Journal Format
70
+
71
+ The tool automatically detects your journal folder from `.foam/templates/daily-note.md`. Falls back to `journal/` if not found.
72
+
73
+ In your daily notes (`journal/YYYY-MM-DD.md`), add a `## Habits` section:
74
+
75
+ ```markdown
76
+ # 2025-01-01
77
+
78
+ ## Habits
79
+
80
+ - Gym
81
+ - Drink water: 3.5L
82
+ - Meditation
83
+
84
+ ## Notes
85
+
86
+ Today was productive...
87
+ ```
88
+
89
+ ### Entry Format
90
+
91
+ - **Boolean habit**: Just the habit name (e.g., `- Gym`)
92
+ - **Quantitative habit**: Name followed by colon and value (e.g., `- Drink water: 3.5L`)
93
+
94
+ The habit name matching is case-insensitive.
95
+
96
+ ## Usage
97
+
98
+ ```bash
99
+ # From your Foam workspace root
100
+ foam-habits
101
+
102
+ # Show last 2 weeks
103
+ foam-habits --weeks 2
104
+
105
+ # Show current month
106
+ foam-habits --current-month
107
+
108
+ # Or use npx without installing
109
+ npx foam-habits --weeks 4
110
+ ```
111
+
112
+ ### Options
113
+
114
+ | Option | Alias | Default | Description |
115
+ | ----------------- | ----- | ------- | ------------------------------------------ |
116
+ | `--weeks` | `-w` | `4` | Number of weeks to display |
117
+ | `--current-month` | `-m` | `false` | Show current month instead of last N weeks |
118
+ | `--help` | `-h` | - | Show help |
119
+
120
+ ## Heatmap Legend
121
+
122
+ | Symbol | Color | Meaning |
123
+ | ------ | ------ | -------------------------------------- |
124
+ | `â–‘` | dim | Not done |
125
+ | `â–’` | red | Low progress (<50% of threshold) |
126
+ | `â–“` | yellow | Partial progress (50-99% of threshold) |
127
+ | `â–ˆ` | green | Complete (meets threshold) |
128
+
129
+ ## Streaks
130
+
131
+ The streak counter shows consecutive days of completion, counting backwards from today. A streak is maintained when the habit meets the threshold requirement each day.
132
+
133
+ When a streak exceeds 7 days, a fire emoji appears for extra motivation.
134
+
135
+ ## Tips
136
+
137
+ 1. **Use thresholds** for flexibility. Set `threshold: 0.8` to consider a habit done at 80% of the goal.
138
+
139
+ 2. **Keep habits simple**. Boolean habits (no goal) are great for habits you just want to track presence/absence.
140
+
141
+ 3. **Run from workspace root**. The tool looks for `.foam/habits.yaml` and `journal/` in the current directory.
142
+
143
+ ## Development
144
+
145
+ ```bash
146
+ git clone https://github.com/olavocarvalho/foam-habits.git
147
+ cd foam-habits
148
+ npm install
149
+ npm run build
150
+ npm test # Run 42 unit tests
151
+ npm run dev # Watch mode
152
+ ```
153
+
154
+ ## Roadmap
155
+
156
+ - [ ] **Habit start date**: Differentiate between days when a habit wasn't tracked yet vs days when it was skipped. Add optional `start-date` config:
157
+ ```yaml
158
+ habits:
159
+ Gym:
160
+ emoji: 💪
161
+ start-date: 2025-01-01 # Days before this show as " " instead of "â–‘"
162
+ ```
163
+ This prevents old days from appearing as "missed" when you add a new habit.
164
+
165
+ - [ ] **Configurable color palette**: Allow customizing colors in `habits.yaml`:
166
+ ```yaml
167
+ theme:
168
+ complete: green
169
+ partial: yellow
170
+ low: red
171
+ title: cyan
172
+ ```
173
+ Support both ANSI color names (theme-adaptive) and hex codes (exact colors).
174
+
175
+ - [ ] **Charts for quantitative habits**: Display line/bar charts for non-boolean habits using [ink-chart](https://github.com/pppp606/ink-chart). Show trends over time for habits like water intake, study minutes, etc.
176
+
177
+ ## License
178
+
179
+ MIT
package/dist/app.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import React from 'react';
2
+ import { type ViewArgs } from './lib/schemas.js';
3
+ type Props = ViewArgs;
4
+ export default function App({ weeks, currentMonth }: Props): React.JSX.Element;
5
+ export {};
package/dist/app.js ADDED
@@ -0,0 +1,39 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ import Heatmap from './components/Heatmap.js';
5
+ import Warnings from './components/Warnings.js';
6
+ import { useHabitData } from './hooks/useHabitData.js';
7
+ import { PALETTE } from './lib/palette.js';
8
+ export default function App({ weeks, currentMonth }) {
9
+ const { habits, dates, warnings, loading, error } = useHabitData({
10
+ weeks,
11
+ currentMonth,
12
+ });
13
+ let content;
14
+ if (error) {
15
+ content = (React.createElement(Box, { flexDirection: "column" },
16
+ React.createElement(Text, { color: PALETTE.red },
17
+ "Error: ",
18
+ error.message)));
19
+ }
20
+ else if (loading) {
21
+ content = (React.createElement(Box, null,
22
+ React.createElement(Text, { color: PALETTE.title },
23
+ React.createElement(Spinner, { type: "dots" })),
24
+ React.createElement(Text, null, " Scanning journal entries...")));
25
+ }
26
+ else if (habits.length === 0) {
27
+ content = (React.createElement(Box, { flexDirection: "column" },
28
+ React.createElement(Text, { color: PALETTE.yellow }, "No habits configured in .foam/habits.yaml")));
29
+ }
30
+ else {
31
+ content = (React.createElement(Box, { flexDirection: "column" },
32
+ React.createElement(Warnings, { warnings: warnings }),
33
+ React.createElement(Heatmap, { habits: habits, dates: dates, weeks: weeks, currentMonth: currentMonth })));
34
+ }
35
+ return (React.createElement(Box, { flexDirection: "column" },
36
+ React.createElement(Text, null, " "),
37
+ content,
38
+ React.createElement(Text, null, " ")));
39
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import React from 'react';
3
+ import { render } from 'ink';
4
+ import meow from 'meow';
5
+ import App from './app.js';
6
+ const cli = meow(`
7
+ Usage
8
+ $ foam-habits [options]
9
+
10
+ Options
11
+ --weeks, -w Number of weeks to display (default: 4)
12
+ --current-month Show current month only
13
+ --help Show this help
14
+
15
+ Examples
16
+ $ foam-habits
17
+ $ foam-habits --weeks 12
18
+ $ foam-habits --current-month
19
+ `, {
20
+ importMeta: import.meta,
21
+ flags: {
22
+ weeks: {
23
+ type: 'number',
24
+ shortFlag: 'w',
25
+ default: 4,
26
+ },
27
+ currentMonth: {
28
+ type: 'boolean',
29
+ default: false,
30
+ },
31
+ },
32
+ });
33
+ render(React.createElement(App, { weeks: cli.flags.weeks, currentMonth: cli.flags.currentMonth }));
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ type CompletionLevel = 0 | 1 | 2 | 3;
3
+ type Props = {
4
+ level: CompletionLevel;
5
+ };
6
+ export default function Cell({ level }: Props): React.JSX.Element;
7
+ export {};
@@ -0,0 +1,19 @@
1
+ import React from 'react';
2
+ import { Text } from 'ink';
3
+ import { PALETTE } from '../lib/palette.js';
4
+ const SYMBOLS = {
5
+ 0: 'â–‘', // Not done
6
+ 1: 'â–’', // Low (<50%)
7
+ 2: 'â–“', // Partial (50-79%)
8
+ 3: 'â–ˆ', // Complete (80%+)
9
+ };
10
+ const COLORS = {
11
+ 0: PALETTE.dimmed,
12
+ 1: PALETTE.red,
13
+ 2: PALETTE.yellow,
14
+ 3: PALETTE.green,
15
+ };
16
+ export default function Cell({ level }) {
17
+ const color = COLORS[level];
18
+ return React.createElement(Text, { color: color }, SYMBOLS[level]);
19
+ }
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ import { type HabitData } from '../lib/schemas.js';
3
+ type Props = {
4
+ habit: HabitData;
5
+ dates: string[];
6
+ nameWidth: number;
7
+ };
8
+ export default function HabitRow({ habit, dates, nameWidth }: Props): React.JSX.Element;
9
+ export {};
@@ -0,0 +1,31 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import Cell from './Cell.js';
4
+ import { PALETTE } from '../lib/palette.js';
5
+ import { getCompletionLevel } from '../lib/tracker.js';
6
+ import { truncateVisual, stripVariationSelectors } from '../lib/string-utils.js';
7
+ export default function HabitRow({ habit, dates, nameWidth }) {
8
+ const streakLabel = habit.streak === 1 ? 'day' : 'days';
9
+ const showFire = habit.streak > 7;
10
+ const displayName = truncateVisual(habit.name, nameWidth);
11
+ // Strip variation selectors for consistent emoji width across terminals
12
+ const emoji = stripVariationSelectors(habit.emoji);
13
+ return (React.createElement(Box, null,
14
+ React.createElement(Box, { minWidth: 3 },
15
+ React.createElement(Text, null, emoji)),
16
+ React.createElement(Box, { minWidth: nameWidth + 1 },
17
+ React.createElement(Text, null, displayName)),
18
+ React.createElement(Box, null, dates.map(date => {
19
+ const value = habit.entries[date];
20
+ const level = getCompletionLevel(value, habit.goal, habit.threshold);
21
+ return React.createElement(Cell, { key: date, level: level });
22
+ })),
23
+ React.createElement(Box, { marginLeft: 1 },
24
+ React.createElement(Text, { color: PALETTE.dimmed },
25
+ "(",
26
+ habit.streak,
27
+ " ",
28
+ streakLabel,
29
+ ")"),
30
+ showFire && React.createElement(Text, null, " \uD83D\uDD25"))));
31
+ }
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ type Props = {
3
+ dates: string[];
4
+ nameWidth: number;
5
+ weeks: number;
6
+ currentMonth: boolean;
7
+ };
8
+ export default function Header({ dates, nameWidth, weeks, currentMonth }: Props): React.JSX.Element;
9
+ export {};
@@ -0,0 +1,65 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { format, parseISO } from 'date-fns';
4
+ import { PALETTE } from '../lib/palette.js';
5
+ import { padEndVisual } from '../lib/string-utils.js';
6
+ export default function Header({ dates, nameWidth, weeks, currentMonth }) {
7
+ const habitLabel = padEndVisual('Habit', nameWidth);
8
+ const labelColumnWidth = 3 + nameWidth + 1; // emoji (3) + name + space
9
+ // Track months and build header lines
10
+ const seenMonths = new Set();
11
+ // Build month line and day line
12
+ // Month labels appear at start of each month
13
+ // Day numbers (2-digit) appear at month starts + every 7 days + last day
14
+ let monthLine = '';
15
+ let dayLine = '';
16
+ dates.forEach((date, index) => {
17
+ const parsed = parseISO(date);
18
+ const monthKey = format(parsed, 'yyyy-MM');
19
+ const day = format(parsed, 'dd'); // 2-digit day (01, 02, ... 31)
20
+ const monthAbbr = format(parsed, 'MMM');
21
+ const isFirstOfMonth = !seenMonths.has(monthKey);
22
+ if (isFirstOfMonth) {
23
+ seenMonths.add(monthKey);
24
+ }
25
+ // Decide what to show at this position
26
+ const showDayLabel = isFirstOfMonth || index === dates.length - 1 || (index + 1) % 7 === 0;
27
+ // Month line: show month abbrev at first of each month
28
+ if (isFirstOfMonth) {
29
+ // Pad to current position if needed
30
+ while (monthLine.length < index) {
31
+ monthLine += ' ';
32
+ }
33
+ monthLine += monthAbbr;
34
+ }
35
+ // Day line: show 2-digit day at key positions
36
+ if (showDayLabel) {
37
+ // Pad to current position if needed
38
+ while (dayLine.length < index) {
39
+ dayLine += ' ';
40
+ }
41
+ dayLine += day;
42
+ }
43
+ });
44
+ // Pad lines to match dates length
45
+ while (monthLine.length < dates.length) {
46
+ monthLine += ' ';
47
+ }
48
+ while (dayLine.length < dates.length) {
49
+ dayLine += ' ';
50
+ }
51
+ return (React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
52
+ React.createElement(Box, { marginBottom: 1 },
53
+ React.createElement(Text, { bold: true, color: PALETTE.title }, "Foam Habits"),
54
+ React.createElement(Text, { color: PALETTE.dimmed }, currentMonth
55
+ ? ` / ${dates[0] ? format(parseISO(dates[0]), 'MMMM yyyy') : ''}`
56
+ : ` / Last ${weeks * 7} Days`)),
57
+ React.createElement(Box, null,
58
+ React.createElement(Box, { minWidth: labelColumnWidth },
59
+ React.createElement(Text, null, " ")),
60
+ React.createElement(Text, { color: PALETTE.dimmed }, monthLine)),
61
+ React.createElement(Box, null,
62
+ React.createElement(Box, { minWidth: labelColumnWidth },
63
+ React.createElement(Text, { color: PALETTE.dimmed }, habitLabel)),
64
+ React.createElement(Text, { color: PALETTE.dimmed }, dayLine))));
65
+ }
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import { type HabitData } from '../lib/schemas.js';
3
+ type Props = {
4
+ habits: HabitData[];
5
+ dates: string[];
6
+ weeks: number;
7
+ currentMonth: boolean;
8
+ };
9
+ export default function Heatmap({ habits, dates, weeks, currentMonth }: Props): React.JSX.Element;
10
+ export {};
@@ -0,0 +1,14 @@
1
+ import React from 'react';
2
+ import { Box } from 'ink';
3
+ import Header from './Header.js';
4
+ import HabitRow from './HabitRow.js';
5
+ const MAX_NAME_WIDTH = 12;
6
+ const MIN_NAME_WIDTH = 5;
7
+ export default function Heatmap({ habits, dates, weeks, currentMonth }) {
8
+ // Calculate the max habit name width for alignment (capped at MAX_NAME_WIDTH)
9
+ const longestName = Math.max(...habits.map(h => h.name.length), MIN_NAME_WIDTH);
10
+ const nameWidth = Math.min(longestName, MAX_NAME_WIDTH);
11
+ return (React.createElement(Box, { flexDirection: "column" },
12
+ React.createElement(Header, { dates: dates, nameWidth: nameWidth, weeks: weeks, currentMonth: currentMonth }),
13
+ habits.map(habit => (React.createElement(HabitRow, { key: habit.name, habit: habit, dates: dates, nameWidth: nameWidth })))));
14
+ }
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ type Props = {
3
+ warnings: string[];
4
+ };
5
+ export default function Warnings({ warnings }: Props): React.JSX.Element | null;
6
+ export {};
@@ -0,0 +1,10 @@
1
+ import { Box, Static, Text } from 'ink';
2
+ import React from 'react';
3
+ import { PALETTE } from '../lib/palette.js';
4
+ export default function Warnings({ warnings }) {
5
+ if (warnings.length === 0) {
6
+ return null;
7
+ }
8
+ return (React.createElement(Static, { items: warnings }, (warning, index) => (React.createElement(Box, { key: index },
9
+ React.createElement(Text, { color: PALETTE.yellow }, warning)))));
10
+ }
@@ -0,0 +1,9 @@
1
+ import { type HabitData, type ViewArgs } from '../lib/schemas.js';
2
+ export type UseHabitDataResult = {
3
+ habits: HabitData[];
4
+ dates: string[];
5
+ warnings: string[];
6
+ loading: boolean;
7
+ error: Error | undefined;
8
+ };
9
+ export declare function useHabitData(args: ViewArgs): UseHabitDataResult;
@@ -0,0 +1,33 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { loadConfig, getRootDir } from '../lib/config.js';
3
+ import { parseJournals } from '../lib/parser.js';
4
+ import { aggregateHabits, getDateRange } from '../lib/tracker.js';
5
+ export function useHabitData(args) {
6
+ const [habits, setHabits] = useState([]);
7
+ const [dates, setDates] = useState([]);
8
+ const [warnings, setWarnings] = useState([]);
9
+ const [loading, setLoading] = useState(true);
10
+ const [error, setError] = useState();
11
+ useEffect(() => {
12
+ try {
13
+ // Load config
14
+ const config = loadConfig();
15
+ const rootDir = getRootDir();
16
+ // Parse journal files
17
+ const { entries, warnings: parseWarnings } = parseJournals(rootDir, config);
18
+ // Get date range for display
19
+ const dateRange = getDateRange(args);
20
+ // Aggregate habit data
21
+ const habitData = aggregateHabits(entries, config, dateRange);
22
+ setHabits(habitData);
23
+ setDates(dateRange);
24
+ setWarnings(parseWarnings);
25
+ setLoading(false);
26
+ }
27
+ catch (err) {
28
+ setError(err instanceof Error ? err : new Error(String(err)));
29
+ setLoading(false);
30
+ }
31
+ }, [args.weeks, args.currentMonth]);
32
+ return { habits, dates, warnings, loading, error };
33
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,171 @@
1
+ import test from 'ava';
2
+ import { extractHabitsSection, parseHabitEntries, parseFolderFromTemplate, } from '../parser.js';
3
+ const mockConfig = {
4
+ habits: {
5
+ Gym: { emoji: '💪', threshold: 1.0 },
6
+ 'Drink water': { emoji: '💧', goal: '4L', threshold: 1.0 },
7
+ Study: { emoji: '📖', goal: '30min', threshold: 1.0 },
8
+ Meditation: { emoji: '🧘', threshold: 1.0 },
9
+ },
10
+ };
11
+ // extractHabitsSection tests
12
+ test('extractHabitsSection: extracts habits section', t => {
13
+ const content = `# 2025-01-01
14
+
15
+ ## Habits
16
+
17
+ - Gym
18
+ - Drink water: 3L
19
+
20
+ ## Notes
21
+
22
+ Some notes here.`;
23
+ const result = extractHabitsSection(content);
24
+ t.truthy(result);
25
+ t.true(result.includes('- Gym'));
26
+ t.true(result.includes('- Drink water: 3L'));
27
+ t.false(result.includes('Some notes'));
28
+ });
29
+ test('extractHabitsSection: handles "Habit" singular', t => {
30
+ const content = `## Habit
31
+
32
+ - Gym`;
33
+ const result = extractHabitsSection(content);
34
+ t.truthy(result);
35
+ t.true(result.includes('- Gym'));
36
+ });
37
+ test('extractHabitsSection: case insensitive header', t => {
38
+ const content = `## HABITS
39
+
40
+ - Gym`;
41
+ const result = extractHabitsSection(content);
42
+ t.truthy(result);
43
+ t.true(result.includes('- Gym'));
44
+ });
45
+ test('extractHabitsSection: returns undefined when no habits section', t => {
46
+ const content = `# 2025-01-01
47
+
48
+ ## Notes
49
+
50
+ Some notes here.`;
51
+ const result = extractHabitsSection(content);
52
+ t.is(result, undefined);
53
+ });
54
+ test('extractHabitsSection: stops at next section', t => {
55
+ const content = `## Habits
56
+
57
+ - Gym
58
+
59
+ ## Other Section
60
+
61
+ - Something else`;
62
+ const result = extractHabitsSection(content);
63
+ t.truthy(result);
64
+ t.true(result.includes('- Gym'));
65
+ t.false(result.includes('Something else'));
66
+ });
67
+ // parseHabitEntries tests
68
+ test('parseHabitEntries: parses boolean habit', t => {
69
+ const { entries, warnings } = parseHabitEntries('- Gym', '2025-01-01', mockConfig, '2025-01-01.md');
70
+ t.is(entries.length, 1);
71
+ t.is(entries[0].name, 'gym');
72
+ t.is(entries[0].value, 1);
73
+ t.is(entries[0].date, '2025-01-01');
74
+ t.is(warnings.length, 0);
75
+ });
76
+ test('parseHabitEntries: parses habit with value', t => {
77
+ const { entries, warnings } = parseHabitEntries('- Drink water: 3.5L', '2025-01-01', mockConfig, '2025-01-01.md');
78
+ t.is(entries.length, 1);
79
+ t.is(entries[0].name, 'drink water');
80
+ t.is(entries[0].value, 3.5);
81
+ t.is(warnings.length, 0);
82
+ });
83
+ test('parseHabitEntries: parses multiple habits', t => {
84
+ const section = `- Gym
85
+ - Drink water: 4L
86
+ - Meditation`;
87
+ const { entries, warnings } = parseHabitEntries(section, '2025-01-01', mockConfig, '2025-01-01.md');
88
+ t.is(entries.length, 3);
89
+ t.is(entries[0].name, 'gym');
90
+ t.is(entries[1].name, 'drink water');
91
+ t.is(entries[1].value, 4);
92
+ t.is(entries[2].name, 'meditation');
93
+ t.is(warnings.length, 0);
94
+ });
95
+ test('parseHabitEntries: warns on unknown habit', t => {
96
+ const { entries, warnings } = parseHabitEntries('- Unknown habit', '2025-01-01', mockConfig, '2025-01-01.md');
97
+ t.is(entries.length, 0);
98
+ t.is(warnings.length, 1);
99
+ t.true(warnings[0].includes('Unknown habit'));
100
+ });
101
+ test('parseHabitEntries: case insensitive habit matching', t => {
102
+ const { entries } = parseHabitEntries('- GYM', '2025-01-01', mockConfig, '2025-01-01.md');
103
+ t.is(entries.length, 1);
104
+ t.is(entries[0].name, 'gym');
105
+ });
106
+ test('parseHabitEntries: handles asterisk list marker', t => {
107
+ const { entries } = parseHabitEntries('* Gym', '2025-01-01', mockConfig, '2025-01-01.md');
108
+ t.is(entries.length, 1);
109
+ t.is(entries[0].name, 'gym');
110
+ });
111
+ test('parseHabitEntries: ignores non-list lines', t => {
112
+ const section = `Some text
113
+ - Gym
114
+ More text`;
115
+ const { entries } = parseHabitEntries(section, '2025-01-01', mockConfig, '2025-01-01.md');
116
+ t.is(entries.length, 1);
117
+ });
118
+ test('parseHabitEntries: extracts integer value', t => {
119
+ const { entries } = parseHabitEntries('- Study: 45min', '2025-01-01', mockConfig, '2025-01-01.md');
120
+ t.is(entries[0].value, 45);
121
+ });
122
+ // parseFolderFromTemplate tests
123
+ test('parseFolderFromTemplate: extracts folder from standard template', t => {
124
+ const content = `---
125
+ foam_template:
126
+ filepath: "/journal/\${FOAM_DATE_YEAR}-\${FOAM_DATE_MONTH}-\${FOAM_DATE_DATE}.md"
127
+ description: "Daily note template"
128
+ ---
129
+ # Content here`;
130
+ t.is(parseFolderFromTemplate(content), 'journal');
131
+ });
132
+ test('parseFolderFromTemplate: extracts folder without leading slash', t => {
133
+ const content = `---
134
+ foam_template:
135
+ filepath: "notes/daily/\${FOAM_DATE}.md"
136
+ ---`;
137
+ t.is(parseFolderFromTemplate(content), 'notes/daily');
138
+ });
139
+ test('parseFolderFromTemplate: handles nested folders', t => {
140
+ const content = `---
141
+ foam_template:
142
+ filepath: "/notes/journal/daily/\${FOAM_DATE}.md"
143
+ ---`;
144
+ t.is(parseFolderFromTemplate(content), 'notes/journal/daily');
145
+ });
146
+ test('parseFolderFromTemplate: handles quoted filepath', t => {
147
+ const content = `---
148
+ foam_template:
149
+ filepath: '/diary/\${FOAM_DATE}.md'
150
+ ---`;
151
+ t.is(parseFolderFromTemplate(content), 'diary');
152
+ });
153
+ test('parseFolderFromTemplate: returns undefined for missing frontmatter', t => {
154
+ const content = `# Just a regular markdown file
155
+ No frontmatter here`;
156
+ t.is(parseFolderFromTemplate(content), undefined);
157
+ });
158
+ test('parseFolderFromTemplate: returns undefined for missing filepath', t => {
159
+ const content = `---
160
+ foam_template:
161
+ description: "No filepath here"
162
+ ---`;
163
+ t.is(parseFolderFromTemplate(content), undefined);
164
+ });
165
+ test('parseFolderFromTemplate: returns undefined for filename-only pattern', t => {
166
+ const content = `---
167
+ foam_template:
168
+ filepath: "\${FOAM_DATE}.md"
169
+ ---`;
170
+ t.is(parseFolderFromTemplate(content), undefined);
171
+ });
@@ -0,0 +1 @@
1
+ export {};