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.
@@ -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,7 @@
1
+ export declare const PALETTE: {
2
+ readonly title: "#9EA5FF";
3
+ readonly red: "#FC8897";
4
+ readonly yellow: "#C0B435";
5
+ readonly green: "#6BC87B";
6
+ readonly dimmed: "#717380";
7
+ };
@@ -0,0 +1,8 @@
1
+ export const PALETTE = {
2
+ title: '#9EA5FF',
3
+ red: '#FC8897',
4
+ yellow: '#C0B435',
5
+ green: '#6BC87B',
6
+ // Dimmed text with a slight tint matching the title hue
7
+ dimmed: '#717380',
8
+ };
@@ -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;