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 +179 -0
- package/dist/app.d.ts +5 -0
- package/dist/app.js +39 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +33 -0
- package/dist/components/Cell.d.ts +7 -0
- package/dist/components/Cell.js +19 -0
- package/dist/components/HabitRow.d.ts +9 -0
- package/dist/components/HabitRow.js +31 -0
- package/dist/components/Header.d.ts +9 -0
- package/dist/components/Header.js +65 -0
- package/dist/components/Heatmap.d.ts +10 -0
- package/dist/components/Heatmap.js +14 -0
- package/dist/components/Warnings.d.ts +6 -0
- package/dist/components/Warnings.js +10 -0
- package/dist/hooks/useHabitData.d.ts +9 -0
- package/dist/hooks/useHabitData.js +33 -0
- package/dist/lib/__tests__/parser.test.d.ts +1 -0
- package/dist/lib/__tests__/parser.test.js +171 -0
- package/dist/lib/__tests__/schemas.test.d.ts +1 -0
- package/dist/lib/__tests__/schemas.test.js +49 -0
- package/dist/lib/__tests__/tracker.test.d.ts +1 -0
- package/dist/lib/__tests__/tracker.test.js +54 -0
- package/dist/lib/config.d.ts +16 -0
- package/dist/lib/config.js +71 -0
- package/dist/lib/palette.d.ts +7 -0
- package/dist/lib/palette.js +8 -0
- package/dist/lib/parser.d.ts +34 -0
- package/dist/lib/parser.js +165 -0
- package/dist/lib/schemas.d.ts +43 -0
- package/dist/lib/schemas.js +47 -0
- package/dist/lib/string-utils.d.ts +16 -0
- package/dist/lib/string-utils.js +43 -0
- package/dist/lib/tracker.d.ts +14 -0
- package/dist/lib/tracker.js +120 -0
- package/package.json +82 -0
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
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
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,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,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,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 {};
|