UNPKG

virtue-cli

Version:

Personal character development CLI tool for tracking virtues and philosophical alignment

259 lines • 12.1 kB
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