virtue-tracker
Version:
Personal character development CLI tool for tracking virtues and philosophical alignment
303 lines • 13.2 kB
JavaScript
import { intro, outro, select, text, log, confirm } from '@clack/prompts';
import { calculateAlignment, calculateCoherence, formatDate, formatDateDisplay } from '../utils/helpers.js';
import picocolors from 'picocolors';
import { writeFileSync } from 'fs';
import { join } from 'path';
export class ExportManager {
db;
constructor(db) {
this.db = db;
}
async exportMenu(profile) {
intro(picocolors.cyan('📤 Export Data'));
try {
const options = await this.gatherExportOptions();
const exportData = await this.gatherExportData(profile, options);
const filename = await this.generateExport(profile, exportData, options);
outro(picocolors.green(`✅ Data exported to: ${filename}`));
}
catch (error) {
if (error === 'cancelled') {
outro(picocolors.gray('Export cancelled'));
}
else {
log.error(`Export failed: ${error}`);
outro(picocolors.red('Export failed'));
}
}
}
async gatherExportOptions() {
const format = await select({
message: 'Select export format:',
options: [
{ value: 'markdown', label: '📝 Markdown', hint: 'Human-readable, great for notes apps' },
{ value: 'json', label: '📊 JSON', hint: 'Machine-readable, for data analysis' },
{ value: 'csv', label: '📋 CSV', hint: 'Spreadsheet format' }
]
});
const period = await select({
message: 'Select time period:',
options: [
{ value: 'all', label: '📅 All time', hint: 'Complete history' },
{ value: '1year', label: '📆 Last year', hint: 'Past 365 days' },
{ value: '90days', label: '🗓️ Last 90 days', hint: 'Recent quarter' },
{ value: '30days', label: '📋 Last 30 days', hint: 'Recent month' }
]
});
const includeAnalytics = await confirm({
message: 'Include analytics and insights?',
initialValue: true
});
if (typeof includeAnalytics === 'symbol')
throw 'cancelled';
const includeNotes = await confirm({
message: 'Include personal notes and reflections?',
initialValue: true
});
if (typeof includeNotes === 'symbol')
throw 'cancelled';
const customFilename = await text({
message: 'Custom filename (optional):',
placeholder: 'Leave empty for auto-generated name'
});
if (typeof customFilename === 'symbol')
throw 'cancelled';
return {
format,
period,
includeAnalytics,
includeNotes,
filename: customFilename || undefined
};
}
async gatherExportData(profile, options) {
let cutoffDate;
if (options.period !== 'all') {
cutoffDate = new Date();
const days = options.period === '30days' ? 30 :
options.period === '90days' ? 90 : 365;
cutoffDate.setDate(cutoffDate.getDate() - days);
}
const rawEntries = this.db.getEntriesForProfile(profile.id, cutoffDate);
const entries = this.parseEntries(rawEntries);
const virtues = this.db.getVirtuesForProfile(profile.id);
let analytics = null;
if (options.includeAnalytics && entries.length > 0) {
analytics = this.calculateAnalytics(entries, virtues);
}
return {
profile,
entries: options.includeNotes ? entries : entries.map(e => ({ ...e, notes: null })),
virtues,
analytics,
exportDate: new Date(),
period: options.period
};
}
parseEntries(rawEntries) {
return rawEntries.map(entry => ({
...entry,
virtue_scores: typeof entry.virtue_scores === 'string'
? JSON.parse(entry.virtue_scores)
: entry.virtue_scores
}));
}
calculateAnalytics(entries, virtues) {
const totalEntries = entries.length;
// Calculate average coherence
const coherences = entries.map(entry => {
const scores = {};
entry.virtue_scores.forEach((vs) => {
scores[vs.virtue_id] = { want: vs.want_score, pull: vs.pull_score };
});
return calculateCoherence(scores);
});
const averageCoherence = coherences.reduce((sum, val) => sum + val, 0) / coherences.length;
// Calculate virtue performance
const virtuePerformance = virtues.map(virtue => {
const virtueEntries = entries.filter(entry => entry.virtue_scores.some((vs) => vs.virtue_id === virtue.id));
const alignments = virtueEntries.map(entry => {
const score = entry.virtue_scores.find((vs) => vs.virtue_id === virtue.id);
return score ? calculateAlignment(score.want_score, score.pull_score) : 0;
}).filter(alignment => alignment > 0);
const avgAlignment = alignments.length > 0
? alignments.reduce((sum, val) => sum + val, 0) / alignments.length
: 0;
return {
virtue: virtue.name,
averageAlignment: avgAlignment,
totalEntries: virtueEntries.length
};
});
// Calculate streaks
const { currentStreak, longestStreak } = this.calculateStreaks(entries);
return {
totalEntries,
averageCoherence,
virtuePerformance: virtuePerformance.sort((a, b) => b.averageAlignment - a.averageAlignment),
currentStreak,
longestStreak,
dateRange: {
start: entries[entries.length - 1]?.date,
end: entries[0]?.date
}
};
}
calculateStreaks(entries) {
if (entries.length === 0)
return { currentStreak: 0, longestStreak: 0 };
const sortedEntries = entries.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
let currentStreak = 0;
let longestStreak = 0;
let tempStreak = 0;
const today = new Date();
let checkDate = new Date(today);
// Calculate current streak
for (const entry of sortedEntries) {
const entryDate = new Date(entry.date);
const daysDiff = Math.floor((checkDate.getTime() - entryDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff === 0 || daysDiff === 1) {
currentStreak++;
checkDate = entryDate;
}
else {
break;
}
}
// Calculate longest streak
for (let i = 0; i < sortedEntries.length; i++) {
tempStreak = 1;
for (let j = i + 1; j < sortedEntries.length; j++) {
const currentDate = new Date(sortedEntries[j - 1].date);
const nextDate = new Date(sortedEntries[j].date);
const daysDiff = Math.floor((currentDate.getTime() - nextDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff <= 1) {
tempStreak++;
}
else {
break;
}
}
longestStreak = Math.max(longestStreak, tempStreak);
}
return { currentStreak, longestStreak };
}
async generateExport(profile, data, options) {
const timestamp = new Date().toISOString().split('T')[0];
const baseFilename = options.filename ||
`virtue-tracker-${profile.name.toLowerCase().replace(/\s+/g, '-')}-${options.period}-${timestamp}`;
let content;
let extension;
switch (options.format) {
case 'markdown':
content = this.generateMarkdown(data, options);
extension = '.md';
break;
case 'json':
content = JSON.stringify(data, null, 2);
extension = '.json';
break;
case 'csv':
content = this.generateCSV(data);
extension = '.csv';
break;
}
const filename = baseFilename + extension;
const filepath = join(process.cwd(), filename);
writeFileSync(filepath, content, 'utf-8');
return filename;
}
generateMarkdown(data, options) {
const { profile, entries, virtues, analytics, exportDate, period } = data;
let md = `# Virtue Tracker Export\n\n`;
md += `**Profile:** ${profile.name}\n`;
md += `**Period:** ${period === 'all' ? 'All time' : period}\n`;
md += `**Exported:** ${formatDateDisplay(exportDate)}\n`;
md += `**Total Entries:** ${entries.length}\n\n`;
// Analytics section
if (analytics) {
md += `## 📊 Analytics Summary\n\n`;
md += `- **Average Coherence:** ${(analytics.averageCoherence * 100).toFixed(1)}%\n`;
md += `- **Current Streak:** ${analytics.currentStreak} days\n`;
md += `- **Longest Streak:** ${analytics.longestStreak} days\n`;
if (analytics.dateRange.start && analytics.dateRange.end) {
md += `- **Date Range:** ${formatDate(new Date(analytics.dateRange.start))} to ${formatDate(new Date(analytics.dateRange.end))}\n`;
}
md += `\n### Virtue Performance\n\n`;
analytics.virtuePerformance.forEach((vp, index) => {
const rank = index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `${index + 1}.`;
md += `${rank} **${vp.virtue}**: ${(vp.averageAlignment * 100).toFixed(1)}% (${vp.totalEntries} entries)\n`;
});
md += `\n`;
}
// Virtues section
md += `## 🎯 Personal Virtues\n\n`;
virtues.forEach((virtue) => {
md += `### ${virtue.emoji} ${virtue.name}\n`;
md += `${virtue.definition}\n\n`;
});
// Entries section
if (entries.length > 0) {
md += `## 📝 Daily Entries\n\n`;
entries.reverse().forEach((entry) => {
md += `### ${formatDateDisplay(new Date(entry.date))}\n\n`;
// Virtue scores
entry.virtue_scores.forEach((vs) => {
const virtue = virtues.find((v) => v.id === vs.virtue_id);
if (virtue) {
const alignment = calculateAlignment(vs.want_score, vs.pull_score);
md += `- **${virtue.name}**: Want ${vs.want_score}/10, Pull ${vs.pull_score}/10 (${(alignment * 100).toFixed(1)}% aligned)\n`;
}
});
// Overall coherence
const scores = {};
entry.virtue_scores.forEach((vs) => {
scores[vs.virtue_id] = { want: vs.want_score, pull: vs.pull_score };
});
const coherence = calculateCoherence(scores);
md += `\n**Overall Coherence:** ${(coherence * 100).toFixed(1)}%\n`;
if (entry.notes && options.includeNotes) {
md += `\n**Notes:** ${entry.notes}\n`;
}
md += `\n---\n\n`;
});
}
md += `## 📋 Export Information\n\n`;
md += `This export was generated by Virtue Tracker CLI on ${formatDateDisplay(exportDate)}.\n`;
md += `Profile: ${profile.name} | Period: ${period} | Format: Markdown\n\n`;
md += `*Keep tracking your virtues and building character!* 🌟\n`;
return md;
}
generateCSV(data) {
const { entries, virtues } = data;
// Header
let csv = 'Date,Overall_Coherence,Completion_Time_Seconds,Notes';
virtues.forEach((virtue) => {
csv += `,${virtue.name}_Want,${virtue.name}_Pull,${virtue.name}_Alignment`;
});
csv += '\n';
// Data rows
entries.forEach((entry) => {
const scores = {};
entry.virtue_scores.forEach((vs) => {
scores[vs.virtue_id] = { want: vs.want_score, pull: vs.pull_score };
});
const coherence = calculateCoherence(scores);
csv += `${entry.date},${coherence.toFixed(4)},${entry.completion_time_seconds || ''},`;
csv += `"${(entry.notes || '').replace(/"/g, '""')}"`;
virtues.forEach((virtue) => {
const score = entry.virtue_scores.find((vs) => vs.virtue_id === virtue.id);
if (score) {
const alignment = calculateAlignment(score.want_score, score.pull_score);
csv += `,${score.want_score},${score.pull_score},${alignment.toFixed(4)}`;
}
else {
csv += ',,,';
}
});
csv += '\n';
});
return csv;
}
}
//# sourceMappingURL=ExportManager.js.map