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
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import test from 'ava';
|
|
2
|
+
import { parseGoal } from '../schemas.js';
|
|
3
|
+
// parseGoal - basic cases
|
|
4
|
+
test('parseGoal: parses integer with unit', t => {
|
|
5
|
+
const result = parseGoal('4L');
|
|
6
|
+
t.is(result.value, 4);
|
|
7
|
+
t.is(result.unit, 'L');
|
|
8
|
+
});
|
|
9
|
+
test('parseGoal: parses decimal with unit', t => {
|
|
10
|
+
const result = parseGoal('2.5km');
|
|
11
|
+
t.is(result.value, 2.5);
|
|
12
|
+
t.is(result.unit, 'km');
|
|
13
|
+
});
|
|
14
|
+
test('parseGoal: parses value with multi-char unit', t => {
|
|
15
|
+
const result = parseGoal('30min');
|
|
16
|
+
t.is(result.value, 30);
|
|
17
|
+
t.is(result.unit, 'min');
|
|
18
|
+
});
|
|
19
|
+
test('parseGoal: parses value with space before unit', t => {
|
|
20
|
+
const result = parseGoal('4 L');
|
|
21
|
+
t.is(result.value, 4);
|
|
22
|
+
t.is(result.unit, 'L');
|
|
23
|
+
});
|
|
24
|
+
test('parseGoal: parses value without unit', t => {
|
|
25
|
+
const result = parseGoal('10');
|
|
26
|
+
t.is(result.value, 10);
|
|
27
|
+
t.is(result.unit, undefined);
|
|
28
|
+
});
|
|
29
|
+
test('parseGoal: parses decimal without unit', t => {
|
|
30
|
+
const result = parseGoal('2.5');
|
|
31
|
+
t.is(result.value, 2.5);
|
|
32
|
+
t.is(result.unit, undefined);
|
|
33
|
+
});
|
|
34
|
+
// parseGoal - edge cases
|
|
35
|
+
test('parseGoal: handles leading zeros', t => {
|
|
36
|
+
const result = parseGoal('08hours');
|
|
37
|
+
t.is(result.value, 8);
|
|
38
|
+
t.is(result.unit, 'hours');
|
|
39
|
+
});
|
|
40
|
+
test('parseGoal: throws on invalid format (no number)', t => {
|
|
41
|
+
t.throws(() => parseGoal('abc'), {
|
|
42
|
+
message: /Invalid goal format/,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
test('parseGoal: throws on empty string', t => {
|
|
46
|
+
t.throws(() => parseGoal(''), {
|
|
47
|
+
message: /Invalid goal format/,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import test from 'ava';
|
|
2
|
+
import { getCompletionLevel } from '../tracker.js';
|
|
3
|
+
// getCompletionLevel - boolean habits (no goal)
|
|
4
|
+
test('getCompletionLevel: undefined value returns 0 (not done)', t => {
|
|
5
|
+
t.is(getCompletionLevel(undefined, undefined), 0);
|
|
6
|
+
});
|
|
7
|
+
test('getCompletionLevel: any value without goal returns 3 (complete)', t => {
|
|
8
|
+
t.is(getCompletionLevel(1, undefined), 3);
|
|
9
|
+
t.is(getCompletionLevel(0.5, undefined), 3);
|
|
10
|
+
t.is(getCompletionLevel(100, undefined), 3);
|
|
11
|
+
});
|
|
12
|
+
// getCompletionLevel - quantitative habits with default threshold (1.0)
|
|
13
|
+
test('getCompletionLevel: meeting goal exactly returns 3', t => {
|
|
14
|
+
t.is(getCompletionLevel(4, 4), 3);
|
|
15
|
+
});
|
|
16
|
+
test('getCompletionLevel: exceeding goal returns 3', t => {
|
|
17
|
+
t.is(getCompletionLevel(5, 4), 3);
|
|
18
|
+
});
|
|
19
|
+
test('getCompletionLevel: 0 value with goal returns 0', t => {
|
|
20
|
+
t.is(getCompletionLevel(0, 4), 0);
|
|
21
|
+
});
|
|
22
|
+
test('getCompletionLevel: low progress returns 1 (red)', t => {
|
|
23
|
+
// With goal=4, threshold=1.0, partialThreshold = 0.625
|
|
24
|
+
// 1/4 = 0.25 < 0.625 -> level 1
|
|
25
|
+
t.is(getCompletionLevel(1, 4), 1);
|
|
26
|
+
});
|
|
27
|
+
test('getCompletionLevel: partial progress returns 2 (yellow)', t => {
|
|
28
|
+
// With goal=4, threshold=1.0, partialThreshold = 0.625
|
|
29
|
+
// 3/4 = 0.75 >= 0.625 but < 1.0 -> level 2
|
|
30
|
+
t.is(getCompletionLevel(3, 4), 2);
|
|
31
|
+
});
|
|
32
|
+
// getCompletionLevel - with custom threshold
|
|
33
|
+
test('getCompletionLevel: custom threshold 0.8 - 80% is complete', t => {
|
|
34
|
+
// 3.2/4 = 0.8 >= threshold 0.8 -> complete
|
|
35
|
+
t.is(getCompletionLevel(3.2, 4, 0.8), 3);
|
|
36
|
+
});
|
|
37
|
+
test('getCompletionLevel: custom threshold 0.8 - 79% is partial', t => {
|
|
38
|
+
// 3.1/4 = 0.775 < threshold 0.8 but >= partialThreshold (0.8 * 0.625 = 0.5)
|
|
39
|
+
t.is(getCompletionLevel(3.1, 4, 0.8), 2);
|
|
40
|
+
});
|
|
41
|
+
test('getCompletionLevel: custom threshold 0.5 - 50% is complete', t => {
|
|
42
|
+
t.is(getCompletionLevel(2, 4, 0.5), 3);
|
|
43
|
+
});
|
|
44
|
+
test('getCompletionLevel: custom threshold 0.5 - 25% is low', t => {
|
|
45
|
+
// 1/4 = 0.25 < partialThreshold (0.5 * 0.625 = 0.3125) -> level 1
|
|
46
|
+
t.is(getCompletionLevel(1, 4, 0.5), 1);
|
|
47
|
+
});
|
|
48
|
+
// Edge cases
|
|
49
|
+
test('getCompletionLevel: very small value is low (not zero)', t => {
|
|
50
|
+
t.is(getCompletionLevel(0.001, 4), 1);
|
|
51
|
+
});
|
|
52
|
+
test('getCompletionLevel: undefined value with goal returns 0', t => {
|
|
53
|
+
t.is(getCompletionLevel(undefined, 4), 0);
|
|
54
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Config } from './schemas.js';
|
|
2
|
+
export declare class ConfigError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Find the .foam/habits.yaml config file by searching up from cwd
|
|
7
|
+
*/
|
|
8
|
+
export declare function findConfigPath(startDir?: string): string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* Load and validate the habits.yaml config file
|
|
11
|
+
*/
|
|
12
|
+
export declare function loadConfig(configPath?: string): Config;
|
|
13
|
+
/**
|
|
14
|
+
* Get the root directory (where .foam/ is located)
|
|
15
|
+
*/
|
|
16
|
+
export declare function getRootDir(configPath?: string): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { ConfigSchema } from './schemas.js';
|
|
5
|
+
export class ConfigError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'ConfigError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Find the .foam/habits.yaml config file by searching up from cwd
|
|
13
|
+
*/
|
|
14
|
+
export function findConfigPath(startDir = process.cwd()) {
|
|
15
|
+
let currentDir = startDir;
|
|
16
|
+
while (currentDir !== path.dirname(currentDir)) {
|
|
17
|
+
const configPath = path.join(currentDir, '.foam', 'habits.yaml');
|
|
18
|
+
if (fs.existsSync(configPath)) {
|
|
19
|
+
return configPath;
|
|
20
|
+
}
|
|
21
|
+
currentDir = path.dirname(currentDir);
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Load and validate the habits.yaml config file
|
|
27
|
+
*/
|
|
28
|
+
export function loadConfig(configPath) {
|
|
29
|
+
const resolvedPath = configPath ?? findConfigPath();
|
|
30
|
+
if (!resolvedPath) {
|
|
31
|
+
throw new ConfigError('Could not find .foam/habits.yaml\n\n' +
|
|
32
|
+
'Create a config file at .foam/habits.yaml with your habits:\n\n' +
|
|
33
|
+
' habits:\n' +
|
|
34
|
+
' gym:\n' +
|
|
35
|
+
' emoji: "🏋️"\n' +
|
|
36
|
+
' water:\n' +
|
|
37
|
+
' emoji: "💧"\n' +
|
|
38
|
+
' goal: 3\n' +
|
|
39
|
+
' unit: "L"\n');
|
|
40
|
+
}
|
|
41
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
42
|
+
throw new ConfigError(`Config file not found: ${resolvedPath}`);
|
|
43
|
+
}
|
|
44
|
+
const raw = fs.readFileSync(resolvedPath, 'utf8');
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = yaml.load(raw);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new ConfigError(`Invalid YAML in ${resolvedPath}:\n${error instanceof Error ? error.message : String(error)}`);
|
|
51
|
+
}
|
|
52
|
+
const result = ConfigSchema.safeParse(parsed);
|
|
53
|
+
if (!result.success) {
|
|
54
|
+
const errors = result.error.issues
|
|
55
|
+
.map(issue => ` - ${issue.path.join('.')}: ${issue.message}`)
|
|
56
|
+
.join('\n');
|
|
57
|
+
throw new ConfigError(`Invalid habits.yaml:\n${errors}`);
|
|
58
|
+
}
|
|
59
|
+
return result.data;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Get the root directory (where .foam/ is located)
|
|
63
|
+
*/
|
|
64
|
+
export function getRootDir(configPath) {
|
|
65
|
+
const resolvedPath = configPath ?? findConfigPath();
|
|
66
|
+
if (!resolvedPath) {
|
|
67
|
+
return process.cwd();
|
|
68
|
+
}
|
|
69
|
+
// .foam/habits.yaml -> go up two levels
|
|
70
|
+
return path.dirname(path.dirname(resolvedPath));
|
|
71
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type Config, type HabitEntry } from './schemas.js';
|
|
2
|
+
export type ParseResult = {
|
|
3
|
+
entries: HabitEntry[];
|
|
4
|
+
warnings: string[];
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Parse the journal folder from daily-note template content
|
|
8
|
+
* Exported for testing
|
|
9
|
+
*/
|
|
10
|
+
export declare function parseFolderFromTemplate(content: string): string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Extract the journal folder from Foam's daily-note template
|
|
13
|
+
* Reads .foam/templates/daily-note.md and parses the filepath from frontmatter
|
|
14
|
+
*/
|
|
15
|
+
export declare function inferJournalFolder(rootDir: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Find all journal markdown files
|
|
18
|
+
*/
|
|
19
|
+
export declare function findJournalFiles(rootDir: string): string[];
|
|
20
|
+
/**
|
|
21
|
+
* Extract the ## Habits section from a markdown file
|
|
22
|
+
*/
|
|
23
|
+
export declare function extractHabitsSection(content: string): string | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Parse habit entries from the ## Habits section
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseHabitEntries(habitsSection: string, date: string, config: Config, filePath: string): {
|
|
28
|
+
entries: HabitEntry[];
|
|
29
|
+
warnings: string[];
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Parse all journal files and extract habit entries
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseJournals(rootDir: string, config: Config): ParseResult;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
// Regex to extract date from filename (YYYY-MM-DD.md)
|
|
4
|
+
const DATE_REGEX = /^(\d{4}-\d{2}-\d{2})\.md$/;
|
|
5
|
+
// Regex to extract value from habit entry (e.g., "Water: 2.5L" -> 2.5)
|
|
6
|
+
const VALUE_REGEX = /:\s*([\d.]+)/;
|
|
7
|
+
// Default folder if template doesn't exist or can't be parsed
|
|
8
|
+
const DEFAULT_JOURNAL_FOLDER = 'journal';
|
|
9
|
+
/**
|
|
10
|
+
* Parse the journal folder from daily-note template content
|
|
11
|
+
* Exported for testing
|
|
12
|
+
*/
|
|
13
|
+
export function parseFolderFromTemplate(content) {
|
|
14
|
+
// Extract YAML frontmatter (between --- markers)
|
|
15
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
16
|
+
if (!frontmatterMatch?.[1]) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
// Extract filepath from frontmatter (e.g., filepath: "/journal/${FOAM_DATE}...")
|
|
20
|
+
const filepathMatch = frontmatterMatch[1].match(/filepath:\s*["']?([^"'\n]+)["']?/);
|
|
21
|
+
if (!filepathMatch?.[1]) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const filepath = filepathMatch[1].trim();
|
|
25
|
+
// Extract folder from filepath (everything before the filename pattern)
|
|
26
|
+
// Handle both "/journal/..." and "journal/..." formats
|
|
27
|
+
const normalizedPath = filepath.startsWith('/') ? filepath.slice(1) : filepath;
|
|
28
|
+
// Find the folder part (before the first ${...} or the last /)
|
|
29
|
+
const folderMatch = normalizedPath.match(/^([^$]+)\//);
|
|
30
|
+
if (folderMatch?.[1]) {
|
|
31
|
+
return folderMatch[1];
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Extract the journal folder from Foam's daily-note template
|
|
37
|
+
* Reads .foam/templates/daily-note.md and parses the filepath from frontmatter
|
|
38
|
+
*/
|
|
39
|
+
export function inferJournalFolder(rootDir) {
|
|
40
|
+
const templatePath = path.join(rootDir, '.foam', 'templates', 'daily-note.md');
|
|
41
|
+
try {
|
|
42
|
+
if (!fs.existsSync(templatePath)) {
|
|
43
|
+
return DEFAULT_JOURNAL_FOLDER;
|
|
44
|
+
}
|
|
45
|
+
const content = fs.readFileSync(templatePath, 'utf8');
|
|
46
|
+
return parseFolderFromTemplate(content) ?? DEFAULT_JOURNAL_FOLDER;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return DEFAULT_JOURNAL_FOLDER;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Find all journal markdown files
|
|
54
|
+
*/
|
|
55
|
+
export function findJournalFiles(rootDir) {
|
|
56
|
+
const journalFolder = inferJournalFolder(rootDir);
|
|
57
|
+
const journalDir = path.join(rootDir, journalFolder);
|
|
58
|
+
if (!fs.existsSync(journalDir)) {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
const files = fs.readdirSync(journalDir);
|
|
62
|
+
return files
|
|
63
|
+
.filter(file => DATE_REGEX.test(file))
|
|
64
|
+
.map(file => path.join(journalDir, file))
|
|
65
|
+
.sort();
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Extract the ## Habits section from a markdown file
|
|
69
|
+
*/
|
|
70
|
+
export function extractHabitsSection(content) {
|
|
71
|
+
const lines = content.split('\n');
|
|
72
|
+
let inHabitsSection = false;
|
|
73
|
+
const habitsLines = [];
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
// Check for ## Habits header (case-insensitive)
|
|
76
|
+
if (/^##\s+habits?\s*$/i.test(line)) {
|
|
77
|
+
inHabitsSection = true;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// Stop at next section header
|
|
81
|
+
if (inHabitsSection && /^##\s+/.test(line)) {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
if (inHabitsSection) {
|
|
85
|
+
habitsLines.push(line);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return habitsLines.length > 0 ? habitsLines.join('\n') : undefined;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Parse habit entries from the ## Habits section
|
|
92
|
+
*/
|
|
93
|
+
export function parseHabitEntries(habitsSection, date, config, filePath) {
|
|
94
|
+
const entries = [];
|
|
95
|
+
const warnings = [];
|
|
96
|
+
const configHabitNames = new Set(Object.keys(config.habits).map(h => h.toLowerCase()));
|
|
97
|
+
const lines = habitsSection.split('\n');
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
// Match lines starting with "- " (list items)
|
|
100
|
+
const match = line.match(/^[-*]\s+(.+)$/);
|
|
101
|
+
if (!match?.[1])
|
|
102
|
+
continue;
|
|
103
|
+
const rawEntry = match[1].trim();
|
|
104
|
+
if (!rawEntry) {
|
|
105
|
+
warnings.push(`⚠ Malformed entry in ${path.basename(filePath)}: "${line}"`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// Extract habit name (everything before ":" or the whole string)
|
|
109
|
+
const colonIndex = rawEntry.indexOf(':');
|
|
110
|
+
const habitName = colonIndex > 0 ? rawEntry.slice(0, colonIndex).trim() : rawEntry;
|
|
111
|
+
const habitNameLower = habitName.toLowerCase();
|
|
112
|
+
// Check if habit is in config
|
|
113
|
+
if (!configHabitNames.has(habitNameLower)) {
|
|
114
|
+
warnings.push(`⚠ Unknown habit "${habitName}" in ${path.basename(filePath)}`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
// Extract optional value
|
|
118
|
+
const valueMatch = rawEntry.match(VALUE_REGEX);
|
|
119
|
+
const value = valueMatch?.[1] ? parseFloat(valueMatch[1]) : undefined;
|
|
120
|
+
entries.push({
|
|
121
|
+
name: habitNameLower,
|
|
122
|
+
value: value ?? 1, // Default to 1 for boolean habits
|
|
123
|
+
date,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return { entries, warnings };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Parse a single journal file
|
|
130
|
+
*/
|
|
131
|
+
function parseJournalFile(filePath, config) {
|
|
132
|
+
const filename = path.basename(filePath);
|
|
133
|
+
const dateMatch = filename.match(DATE_REGEX);
|
|
134
|
+
if (!dateMatch?.[1]) {
|
|
135
|
+
return { entries: [], warnings: [`⚠ Invalid filename: ${filename}`] };
|
|
136
|
+
}
|
|
137
|
+
const date = dateMatch[1];
|
|
138
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
139
|
+
const habitsSection = extractHabitsSection(content);
|
|
140
|
+
if (!habitsSection) {
|
|
141
|
+
return { entries: [], warnings: [] };
|
|
142
|
+
}
|
|
143
|
+
return parseHabitEntries(habitsSection, date, config, filePath);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Parse all journal files and extract habit entries
|
|
147
|
+
*/
|
|
148
|
+
export function parseJournals(rootDir, config) {
|
|
149
|
+
const journalFolder = inferJournalFolder(rootDir);
|
|
150
|
+
const files = findJournalFiles(rootDir);
|
|
151
|
+
const allEntries = [];
|
|
152
|
+
const allWarnings = [];
|
|
153
|
+
if (files.length === 0) {
|
|
154
|
+
allWarnings.push(`⚠ No journal files found in ${journalFolder}/`);
|
|
155
|
+
}
|
|
156
|
+
for (const file of files) {
|
|
157
|
+
const { entries, warnings } = parseJournalFile(file, config);
|
|
158
|
+
allEntries.push(...entries);
|
|
159
|
+
allWarnings.push(...warnings);
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
entries: allEntries,
|
|
163
|
+
warnings: allWarnings,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Parse a goal string like "4L", "30min", "2.5km" into value and unit
|
|
4
|
+
*/
|
|
5
|
+
export declare function parseGoal(goalStr: string): {
|
|
6
|
+
value: number;
|
|
7
|
+
unit: string | undefined;
|
|
8
|
+
};
|
|
9
|
+
export declare const HabitConfigSchema: z.ZodObject<{
|
|
10
|
+
emoji: z.ZodString;
|
|
11
|
+
goal: z.ZodOptional<z.ZodString>;
|
|
12
|
+
threshold: z.ZodDefault<z.ZodNumber>;
|
|
13
|
+
}, z.core.$strip>;
|
|
14
|
+
export declare const ConfigSchema: z.ZodObject<{
|
|
15
|
+
habits: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
16
|
+
emoji: z.ZodString;
|
|
17
|
+
goal: z.ZodOptional<z.ZodString>;
|
|
18
|
+
threshold: z.ZodDefault<z.ZodNumber>;
|
|
19
|
+
}, z.core.$strip>>;
|
|
20
|
+
}, z.core.$strip>;
|
|
21
|
+
export declare const HabitEntrySchema: z.ZodObject<{
|
|
22
|
+
name: z.ZodString;
|
|
23
|
+
value: z.ZodOptional<z.ZodNumber>;
|
|
24
|
+
date: z.ZodString;
|
|
25
|
+
}, z.core.$strip>;
|
|
26
|
+
export declare const HabitDataSchema: z.ZodObject<{
|
|
27
|
+
name: z.ZodString;
|
|
28
|
+
emoji: z.ZodString;
|
|
29
|
+
goal: z.ZodOptional<z.ZodNumber>;
|
|
30
|
+
unit: z.ZodOptional<z.ZodString>;
|
|
31
|
+
threshold: z.ZodDefault<z.ZodNumber>;
|
|
32
|
+
entries: z.ZodRecord<z.ZodString, z.ZodOptional<z.ZodNumber>>;
|
|
33
|
+
streak: z.ZodNumber;
|
|
34
|
+
}, z.core.$strip>;
|
|
35
|
+
export declare const ViewArgsSchema: z.ZodObject<{
|
|
36
|
+
weeks: z.ZodDefault<z.ZodNumber>;
|
|
37
|
+
currentMonth: z.ZodDefault<z.ZodBoolean>;
|
|
38
|
+
}, z.core.$strip>;
|
|
39
|
+
export type HabitConfig = z.infer<typeof HabitConfigSchema>;
|
|
40
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
41
|
+
export type HabitEntry = z.infer<typeof HabitEntrySchema>;
|
|
42
|
+
export type HabitData = z.infer<typeof HabitDataSchema>;
|
|
43
|
+
export type ViewArgs = z.infer<typeof ViewArgsSchema>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Parse a goal string like "4L", "30min", "2.5km" into value and unit
|
|
4
|
+
*/
|
|
5
|
+
export function parseGoal(goalStr) {
|
|
6
|
+
const match = goalStr.match(/^([\d.]+)\s*(.*)$/);
|
|
7
|
+
if (!match?.[1]) {
|
|
8
|
+
throw new Error(`Invalid goal format: "${goalStr}". Expected format like "4L", "30min", "2.5km"`);
|
|
9
|
+
}
|
|
10
|
+
const value = parseFloat(match[1]);
|
|
11
|
+
const unit = match[2]?.trim() || undefined;
|
|
12
|
+
return { value, unit };
|
|
13
|
+
}
|
|
14
|
+
// Single habit definition in config (.foam/habits.yaml)
|
|
15
|
+
export const HabitConfigSchema = z.object({
|
|
16
|
+
emoji: z.string().min(1),
|
|
17
|
+
// Goal with unit, e.g., "4L", "30min", "2.5km" (optional for boolean habits)
|
|
18
|
+
goal: z.string().optional(),
|
|
19
|
+
// Threshold percentage to consider habit "done" (0.0 - 1.0, default 1.0 = 100%)
|
|
20
|
+
threshold: z.number().min(0).max(1).default(1.0),
|
|
21
|
+
});
|
|
22
|
+
// Full habits.yaml config
|
|
23
|
+
export const ConfigSchema = z.object({
|
|
24
|
+
habits: z.record(z.string(), HabitConfigSchema),
|
|
25
|
+
});
|
|
26
|
+
// Parsed habit entry from a daily note
|
|
27
|
+
export const HabitEntrySchema = z.object({
|
|
28
|
+
name: z.string().min(1),
|
|
29
|
+
value: z.number().positive().optional(),
|
|
30
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
31
|
+
});
|
|
32
|
+
// Aggregated habit data for display
|
|
33
|
+
export const HabitDataSchema = z.object({
|
|
34
|
+
name: z.string(),
|
|
35
|
+
emoji: z.string(),
|
|
36
|
+
goal: z.number().optional(),
|
|
37
|
+
unit: z.string().optional(),
|
|
38
|
+
threshold: z.number().default(1.0),
|
|
39
|
+
// date (YYYY-MM-DD) -> value (undefined = not done, number = value or 1 for boolean)
|
|
40
|
+
entries: z.record(z.string(), z.number().optional()),
|
|
41
|
+
streak: z.number().int().nonnegative(),
|
|
42
|
+
});
|
|
43
|
+
// CLI args for view command
|
|
44
|
+
export const ViewArgsSchema = z.object({
|
|
45
|
+
weeks: z.number().int().positive().default(4),
|
|
46
|
+
currentMonth: z.boolean().default(false),
|
|
47
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip variation selectors (U+FE0E, U+FE0F) from emojis for consistent rendering
|
|
3
|
+
*/
|
|
4
|
+
export declare function stripVariationSelectors(str: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* Pad a string to a target visual width, accounting for emoji/unicode widths
|
|
7
|
+
*/
|
|
8
|
+
export declare function padEndVisual(str: string, targetWidth: number): string;
|
|
9
|
+
/**
|
|
10
|
+
* Truncate a string to a max visual width, adding "…" if truncated
|
|
11
|
+
*/
|
|
12
|
+
export declare function truncateVisual(str: string, maxWidth: number): string;
|
|
13
|
+
/**
|
|
14
|
+
* Get the visual width of a string
|
|
15
|
+
*/
|
|
16
|
+
export declare function getVisualWidth(str: string): number;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import stringWidth from 'string-width';
|
|
2
|
+
/**
|
|
3
|
+
* Strip variation selectors (U+FE0E, U+FE0F) from emojis for consistent rendering
|
|
4
|
+
*/
|
|
5
|
+
export function stripVariationSelectors(str) {
|
|
6
|
+
// eslint-disable-next-line no-control-regex
|
|
7
|
+
return str.replace(/[\uFE0E\uFE0F]/g, '');
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Pad a string to a target visual width, accounting for emoji/unicode widths
|
|
11
|
+
*/
|
|
12
|
+
export function padEndVisual(str, targetWidth) {
|
|
13
|
+
const currentWidth = stringWidth(str);
|
|
14
|
+
const padding = Math.max(0, targetWidth - currentWidth);
|
|
15
|
+
return str + ' '.repeat(padding);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Truncate a string to a max visual width, adding "…" if truncated
|
|
19
|
+
*/
|
|
20
|
+
export function truncateVisual(str, maxWidth) {
|
|
21
|
+
const currentWidth = stringWidth(str);
|
|
22
|
+
if (currentWidth <= maxWidth) {
|
|
23
|
+
return padEndVisual(str, maxWidth);
|
|
24
|
+
}
|
|
25
|
+
// Need to truncate - find where to cut
|
|
26
|
+
let truncated = '';
|
|
27
|
+
let width = 0;
|
|
28
|
+
for (const char of str) {
|
|
29
|
+
const charWidth = stringWidth(char);
|
|
30
|
+
if (width + charWidth > maxWidth - 1) {
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
truncated += char;
|
|
34
|
+
width += charWidth;
|
|
35
|
+
}
|
|
36
|
+
return padEndVisual(truncated + '…', maxWidth);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Get the visual width of a string
|
|
40
|
+
*/
|
|
41
|
+
export function getVisualWidth(str) {
|
|
42
|
+
return stringWidth(str);
|
|
43
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Config, type HabitEntry, type HabitData, type ViewArgs } from './schemas.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generate date range based on view args
|
|
4
|
+
*/
|
|
5
|
+
export declare function getDateRange(args: ViewArgs): string[];
|
|
6
|
+
/**
|
|
7
|
+
* Aggregate habit entries into HabitData for rendering
|
|
8
|
+
*/
|
|
9
|
+
export declare function aggregateHabits(entries: HabitEntry[], config: Config, dateRange: string[]): HabitData[];
|
|
10
|
+
/**
|
|
11
|
+
* Get completion level for a single cell (0-3)
|
|
12
|
+
* 0 = not done (░), 1 = low (▒), 2 = partial (▓), 3 = complete (█)
|
|
13
|
+
*/
|
|
14
|
+
export declare function getCompletionLevel(value: number | undefined, goal: number | undefined, threshold?: number): 0 | 1 | 2 | 3;
|