Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 95 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@ import { Executor } from './core/executor.js';
import { Watcher } from './core/watcher.js';
import { Database } from './storage/database.js';
import { ModelManager } from './ai/model-manager.js';
import { renderScanStats, renderFileTable } from './ui/table.js';
import {
renderScanStats,
renderFileTable,
renderStatsOverview,
renderTopRules,
renderDuplicateStats,
renderOverallStats,
type StatsData,
} from './ui/table.js';
import { logger } from './utils/logger.js';
import { showBanner } from './ui/banner.js';

Expand Down Expand Up @@ -652,6 +660,92 @@ program
console.log(chalk.yellow('\n Unknown action. Use: list, add, test <file>, edit\n'));
});

// ═══════════════════════════════════════════════════════════════
// STATS command
// ═══════════════════════════════════════════════════════════════
program
.command('stats')
.description('Show organization statistics and reports')
.option('--json', 'Output as JSON')
.option('--period <period>', 'Show stats for specific period (day, week, month)', 'all')
.option('--rules', 'Show only top rules statistics')
.option('--duplicates', 'Show only duplicate statistics')
.action(async (options) => {
const paths = getAppPaths();
const db = new Database(paths.databaseFile);
await db.init();

const spinner = ora('Gathering statistics...').start();

try {
// Gather all statistics
const statsData: StatsData = {
day: db.getOperationsByPeriod('day'),
week: db.getOperationsByPeriod('week'),
month: db.getOperationsByPeriod('month'),
topRules: db.getTopRules(10),
duplicates: db.getDuplicateStats(),
deletedDuplicates: db.getDeletedDuplicatesStats(),
overall: db.getStats(),
};

spinner.succeed('Statistics gathered');

if (options.json) {
console.log(JSON.stringify(statsData, null, 2));
return;
}

// Render specific sections or all
if (options.rules) {
renderTopRules(statsData.topRules);
} else if (options.duplicates) {
renderDuplicateStats(statsData);
} else if (options.period !== 'all') {
// Show specific period
const period = options.period as 'day' | 'week' | 'month';
const periodData = statsData[period];

if (!periodData) {
console.log(chalk.red(`\n Invalid period: ${options.period}. Use day, week, or month.\n`));
return;
}

const periodNames = { day: 'Today', week: 'This Week', month: 'This Month' };
console.log(chalk.bold(`\n Statistics for ${periodNames[period]}\n`));
console.log(` Files organized: ${chalk.cyan(periodData.total.toString())}`);

if (Object.keys(periodData.byType).length > 0) {
console.log(chalk.bold('\n Operations by type:'));
for (const [type, count] of Object.entries(periodData.byType)) {
const typeColor = type === 'delete' ? chalk.red : chalk.cyan;
console.log(` ${typeColor(type)}: ${count}`);
}
}

if (Object.keys(periodData.byRule).length > 0) {
console.log(chalk.bold('\n Operations by rule:'));
const sortedRules = Object.entries(periodData.byRule).sort((a, b) => b[1] - a[1]);
for (const [rule, count] of sortedRules.slice(0, 10)) {
console.log(` ${chalk.cyan(rule)}: ${count}`);
}
}
console.log();
} else {
// Show all statistics
renderStatsOverview(statsData);
renderTopRules(statsData.topRules);
renderDuplicateStats(statsData);
renderOverallStats(statsData);
console.log();
}
} catch (error) {
spinner.fail('Failed to gather statistics');
console.error(error);
process.exit(1);
}
});

// Show animated banner when run without arguments
async function main() {
const args = process.argv.slice(2);
Expand Down
150 changes: 150 additions & 0 deletions src/storage/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,4 +420,154 @@ export class Database {
categories,
};
}

// ═══════════════════════════════════════════════════════════════
// Extended Stats
// ═══════════════════════════════════════════════════════════════

getOperationsByPeriod(period: 'day' | 'week' | 'month'): {
total: number;
byType: Record<string, number>;
byRule: Record<string, number>;
} {
const now = Math.floor(Date.now() / 1000);
let since: number;

switch (period) {
case 'day':
since = now - 24 * 60 * 60;
break;
case 'week':
since = now - 7 * 24 * 60 * 60;
break;
case 'month':
since = now - 30 * 24 * 60 * 60;
break;
}

const total = this.queryOne<{ count: number }>(
'SELECT COUNT(*) as count FROM operations WHERE created_at >= ? AND undone_at IS NULL',
[since]
) || { count: 0 };

const byTypeRows = this.queryAll<{ type: string; count: number }>(`
SELECT type, COUNT(*) as count
FROM operations
WHERE created_at >= ? AND undone_at IS NULL
GROUP BY type
ORDER BY count DESC
`, [since]);

const byType: Record<string, number> = {};
for (const row of byTypeRows) {
byType[row.type] = row.count;
}

const byRuleRows = this.queryAll<{ rule_name: string; count: number }>(`
SELECT rule_name, COUNT(*) as count
FROM operations
WHERE created_at >= ? AND undone_at IS NULL AND rule_name IS NOT NULL
GROUP BY rule_name
ORDER BY count DESC
`, [since]);

const byRule: Record<string, number> = {};
for (const row of byRuleRows) {
byRule[row.rule_name] = row.count;
}

return {
total: total.count,
byType,
byRule,
};
}

getTopRules(limit = 10): { ruleName: string; count: number; lastUsed: number }[] {
const rows = this.queryAll<{ rule_name: string; count: number; last_used: number }>(`
SELECT rule_name, COUNT(*) as count, MAX(created_at) as last_used
FROM operations
WHERE rule_name IS NOT NULL AND undone_at IS NULL
GROUP BY rule_name
ORDER BY count DESC
LIMIT ?
`, [limit]);

return rows.map(row => ({
ruleName: row.rule_name,
count: row.count,
lastUsed: row.last_used,
}));
}

getDuplicateStats(): {
totalGroups: number;
totalDuplicateFiles: number;
potentialSavings: number;
} {
const duplicateHashes = this.queryAll<{ hash: string; count: number; size: number }>(`
SELECT hash, COUNT(*) as count, size
FROM files
WHERE hash IS NOT NULL
GROUP BY hash
HAVING count > 1
`);

let totalGroups = 0;
let totalDuplicateFiles = 0;
let potentialSavings = 0;

for (const row of duplicateHashes) {
totalGroups++;
totalDuplicateFiles += row.count - 1;
potentialSavings += row.size * (row.count - 1);
}

return {
totalGroups,
totalDuplicateFiles,
potentialSavings,
};
}

getDeletedDuplicatesStats(): {
totalDeleted: number;
totalSaved: number;
} {
const result = this.queryOne<{ count: number }>(`
SELECT COUNT(*) as count
FROM operations
WHERE type = 'delete' AND undone_at IS NULL
`) || { count: 0 };

// Get sizes of deleted files from operation source paths
// Note: We can't get exact size from deleted files, so we estimate
// based on average file size from duplicates that were deleted
const avgSize = this.queryOne<{ avg_size: number }>(`
SELECT AVG(f.size) as avg_size
FROM files f
JOIN operations o ON f.path = o.source
WHERE o.type = 'delete'
`) || { avg_size: 0 };

return {
totalDeleted: result.count,
totalSaved: Math.floor(result.count * (avgSize.avg_size || 0)),
};
}

getActivityTimeline(days = 30): { date: string; count: number }[] {
const now = Math.floor(Date.now() / 1000);
const since = now - days * 24 * 60 * 60;

const rows = this.queryAll<{ date: string; count: number }>(`
SELECT date(created_at, 'unixepoch') as date, COUNT(*) as count
FROM operations
WHERE created_at >= ? AND undone_at IS NULL
GROUP BY date
ORDER BY date ASC
`, [since]);

return rows;
}
}
Loading
Loading