virtue-cli
Version:
Personal character development CLI tool for tracking virtues and philosophical alignment
259 lines ⢠12.1 kB
JavaScript
import { intro, outro } from '@clack/prompts';
import { calculateAlignment, calculateCoherence, formatDateDisplay } from '../utils/helpers.js';
import picocolors from 'picocolors';
export class Analytics {
db;
profile;
constructor(db, profile) {
this.db = db;
this.profile = profile;
}
async show(days = 30) {
intro(picocolors.cyan('š Analytics Dashboard'));
const analytics = await this.calculateAnalytics(days);
this.displayOverview(analytics, days);
this.displayCoherenceTrend(analytics);
this.displayVirtueBreakdown(analytics);
this.displayStreakInfo(analytics);
this.displayRecentActivity(analytics);
outro(picocolors.gray(`Analysis based on ${analytics.totalEntries} entries over ${days} days`));
}
async calculateAnalytics(days) {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - days);
const rawEntries = this.db.getEntriesForProfile(this.profile.id, cutoffDate);
const entries = this.parseEntries(rawEntries);
const virtues = this.db.getVirtuesForProfile(this.profile.id);
// Calculate virtue-specific metrics
const virtueScores = 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,
avgAlignment,
entries: virtueEntries.length
};
});
// Find best and challenging virtues
const sortedVirtues = virtueScores.filter(vs => vs.entries > 0).sort((a, b) => b.avgAlignment - a.avgAlignment);
const bestVirtue = sortedVirtues[0] || { virtue: { name: 'None' }, avgAlignment: 0 };
const challengingVirtue = sortedVirtues[sortedVirtues.length - 1] || { virtue: { name: 'None' }, avgAlignment: 0 };
// Calculate streaks
const allRawEntries = this.db.getEntriesForProfile(this.profile.id);
const allEntries = this.parseEntries(allRawEntries);
const { currentStreak, longestStreak } = this.calculateStreaks(allEntries);
// Calculate trend
const recentEntries = entries.slice(-14); // Last 2 weeks
const olderEntries = entries.slice(-28, -14); // Previous 2 weeks
const recentAvg = this.getAverageCoherence(recentEntries);
const olderAvg = this.getAverageCoherence(olderEntries);
let trendDirection = 'stable';
if (recentAvg > olderAvg + 0.05)
trendDirection = 'improving';
else if (recentAvg < olderAvg - 0.05)
trendDirection = 'declining';
return {
totalEntries: entries.length,
averageCoherence: this.getAverageCoherence(entries),
bestVirtue: { name: bestVirtue.virtue.name, score: bestVirtue.avgAlignment },
challengingVirtue: { name: challengingVirtue.virtue.name, score: challengingVirtue.avgAlignment },
currentStreak,
longestStreak,
trendDirection,
recentEntries: entries.slice(-7), // Last week
virtueScores
};
}
parseEntries(rawEntries) {
return rawEntries.map(entry => ({
...entry,
virtue_scores: typeof entry.virtue_scores === 'string'
? JSON.parse(entry.virtue_scores)
: entry.virtue_scores
}));
}
getAverageCoherence(entries) {
if (entries.length === 0)
return 0;
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);
});
return coherences.reduce((sum, val) => sum + val, 0) / coherences.length;
}
calculateStreaks(entries) {
if (entries.length === 0)
return { currentStreak: 0, longestStreak: 0 };
// Sort entries by date
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 };
}
displayOverview(analytics, days) {
const trendIcon = analytics.trendDirection === 'improving' ? 'š' :
analytics.trendDirection === 'declining' ? 'š' : 'š';
const trendColor = analytics.trendDirection === 'improving' ? picocolors.green :
analytics.trendDirection === 'declining' ? picocolors.red : picocolors.yellow;
console.log(picocolors.bold('\nš Overview'));
console.log('ā'.repeat(50));
console.log(`š
Period: ${days} days`);
console.log(`š Total entries: ${picocolors.cyan(analytics.totalEntries.toString())}`);
console.log(`šÆ Average coherence: ${this.formatCoherence(analytics.averageCoherence)}`);
console.log(`${trendIcon} Trend: ${trendColor(analytics.trendDirection)}`);
console.log(`š Best virtue: ${picocolors.green(analytics.bestVirtue.name)} (${(analytics.bestVirtue.score * 100).toFixed(1)}%)`);
console.log(`šŖ Growth opportunity: ${picocolors.yellow(analytics.challengingVirtue.name)} (${(analytics.challengingVirtue.score * 100).toFixed(1)}%)`);
}
displayCoherenceTrend(analytics) {
console.log(picocolors.bold('\nš Coherence Trend'));
console.log('ā'.repeat(50));
if (analytics.recentEntries.length < 3) {
console.log(picocolors.gray('Need more entries to show trend (minimum 3)'));
return;
}
// Create a simple ASCII chart
const recentCoherences = analytics.recentEntries.map(entry => {
const scores = {};
entry.virtue_scores.forEach(vs => {
scores[vs.virtue_id] = { want: vs.want_score, pull: vs.pull_score };
});
return calculateCoherence(scores);
});
this.drawChart(recentCoherences, 'Last 7 days');
}
displayVirtueBreakdown(analytics) {
console.log(picocolors.bold('\nšÆ Virtue Performance'));
console.log('ā'.repeat(50));
const sortedVirtues = analytics.virtueScores
.filter(vs => vs.entries > 0)
.sort((a, b) => b.avgAlignment - a.avgAlignment);
if (sortedVirtues.length === 0) {
console.log(picocolors.gray('No virtue data available'));
return;
}
sortedVirtues.forEach((vs, index) => {
const percentage = (vs.avgAlignment * 100).toFixed(1);
const bar = this.createBar(vs.avgAlignment, 20);
const ranking = index === 0 ? 'š„' : index === 1 ? 'š„' : index === 2 ? 'š„' : ' ';
console.log(`${ranking} ${vs.virtue.name.padEnd(20)} ${bar} ${percentage}% (${vs.entries} entries)`);
});
}
displayStreakInfo(analytics) {
console.log(picocolors.bold('\nš„ Consistency'));
console.log('ā'.repeat(50));
console.log(`š„ Current streak: ${picocolors.cyan(analytics.currentStreak.toString())} days`);
console.log(`š Longest streak: ${picocolors.green(analytics.longestStreak.toString())} days`);
if (analytics.currentStreak === 0) {
console.log(picocolors.yellow('š” Start a new streak today with a quick check-in!'));
}
else if (analytics.currentStreak >= 7) {
console.log(picocolors.green('š Amazing consistency! Keep it up!'));
}
else if (analytics.currentStreak >= 3) {
console.log(picocolors.cyan('š Building good habits!'));
}
}
displayRecentActivity(analytics) {
console.log(picocolors.bold('\nš Recent Activity'));
console.log('ā'.repeat(50));
if (analytics.recentEntries.length === 0) {
console.log(picocolors.gray('No recent entries'));
return;
}
analytics.recentEntries.slice(-5).reverse().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);
const date = formatDateDisplay(new Date(entry.date));
console.log(`${date}: ${this.formatCoherence(coherence)} ${entry.notes ? 'š' : ''}`);
});
}
formatCoherence(coherence) {
const percentage = (coherence * 100).toFixed(1);
const color = coherence >= 0.8 ? picocolors.green :
coherence >= 0.6 ? picocolors.yellow : picocolors.red;
const icon = coherence >= 0.8 ? 'š¢' : coherence >= 0.6 ? 'š”' : 'š“';
return `${color(percentage + '%')} ${icon}`;
}
createBar(value, length) {
const filled = Math.round(value * length);
const empty = length - filled;
return picocolors.green('ā'.repeat(filled)) + picocolors.gray('ā'.repeat(empty));
}
drawChart(values, title) {
console.log(picocolors.bold(title));
const maxVal = Math.max(...values);
const minVal = Math.min(...values);
const range = maxVal - minVal || 1;
const height = 8;
const width = values.length * 3;
// Draw chart from top to bottom
for (let row = height; row >= 0; row--) {
let line = '';
const threshold = minVal + (range * row / height);
for (let i = 0; i < values.length; i++) {
const value = values[i];
if (value >= threshold) {
line += picocolors.cyan('āā ');
}
else {
line += ' ';
}
}
// Add y-axis labels
const label = ((threshold * 100).toFixed(0) + '%').padStart(4);
console.log(`${picocolors.gray(label)} ā${line}`);
}
// Draw x-axis
console.log(' ā' + 'ā'.repeat(width));
// Draw x-axis labels (days)
let xLabels = ' ';
for (let i = 0; i < values.length; i++) {
xLabels += `${(-values.length + i + 1).toString().padStart(2)} `;
}
console.log(picocolors.gray(xLabels));
}
}
//# sourceMappingURL=Analytics.js.map