tractatus/scripts/plan-reminder.js
TheFlow 2298d36bed fix(submissions): restructure Economist package and fix article display
- Create Economist SubmissionTracking package correctly:
  * mainArticle = full blog post content
  * coverLetter = 216-word SIR— letter
  * Links to blog post via blogPostId
- Archive 'Letter to The Economist' from blog posts (it's the cover letter)
- Fix date display on article cards (use published_at)
- Target publication already displaying via blue badge

Database changes:
- Make blogPostId optional in SubmissionTracking model
- Economist package ID: 68fa85ae49d4900e7f2ecd83
- Le Monde package ID: 68fa2abd2e6acd5691932150

Next: Enhanced modal with tabs, validation, export

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 08:47:42 +13:00

457 lines
14 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Plan Reminder System
* Scans for plan documents, tracks status, and reminds about reviews
*/
const fs = require('fs').promises;
const path = require('path');
const PLAN_REGISTRY = path.join(__dirname, '../.claude/plan-registry.json');
const PROJECT_ROOT = path.join(__dirname, '..');
// Only search within tractatus directory, exclude other projects
const SEARCH_DIRS = [
path.join(PROJECT_ROOT, 'docs/plans'),
path.join(PROJECT_ROOT, 'docs'),
path.join(PROJECT_ROOT, 'docs/research'),
path.join(PROJECT_ROOT, 'docs/planning'),
path.join(PROJECT_ROOT, 'docs/governance')
];
// Exclude these directories and projects
const EXCLUDE_PATTERNS = [
/node_modules/,
/\.git/,
/sydigital/,
/passport-consolidated/,
/family-history/,
/mysy/
];
// Plan document patterns
const PLAN_PATTERNS = [
/plan.*\.md$/i,
/roadmap.*\.md$/i,
/session.*handoff.*\.md$/i,
/priority.*\.md$/i,
/-plan\.md$/i
];
/**
* Parse plan metadata from markdown
*/
function parsePlanMetadata(content, filepath) {
const metadata = {
filepath: filepath,
filename: path.basename(filepath),
title: null,
status: null,
priority: null,
created: null,
due: null,
review_schedule: null,
next_review: null,
owner: null,
completeness: null,
last_modified: null
};
// Extract title (first H1)
const titleMatch = content.match(/^#\s+(.+)$/m);
if (titleMatch) {
metadata.title = titleMatch[1].trim();
}
// Extract metadata fields
const statusMatch = content.match(/\*\*Status:\*\*\s*(.+)/i);
if (statusMatch) {
metadata.status = statusMatch[1].trim();
}
const priorityMatch = content.match(/\*\*Priority:\*\*\s*(.+)/i);
if (priorityMatch) {
metadata.priority = priorityMatch[1].trim();
}
const createdMatch = content.match(/\*\*(?:Plan Created|Created):\*\*\s*(.+)/i);
if (createdMatch) {
metadata.created = createdMatch[1].trim();
}
const dueMatch = content.match(/\*\*(?:Target Completion|Due):\*\*\s*(.+)/i);
if (dueMatch) {
metadata.due = dueMatch[1].trim();
}
const reviewMatch = content.match(/\*\*Review Schedule:\*\*\s*(.+)/i);
if (reviewMatch) {
metadata.review_schedule = reviewMatch[1].trim();
}
const nextReviewMatch = content.match(/\*\*Next (?:Review|review):\*\*\s*(.+)/i);
if (nextReviewMatch) {
metadata.next_review = nextReviewMatch[1].trim();
}
const ownerMatch = content.match(/\*\*(?:Plan )?Owner:\*\*\s*(.+)/i);
if (ownerMatch) {
metadata.owner = ownerMatch[1].trim();
}
// Analyze completeness based on checkboxes
const totalCheckboxes = (content.match(/\[[ x✓]\]/gi) || []).length;
const checkedBoxes = (content.match(/\[[x✓]\]/gi) || []).length;
if (totalCheckboxes > 0) {
metadata.completeness = {
total: totalCheckboxes,
completed: checkedBoxes,
percentage: Math.round((checkedBoxes / totalCheckboxes) * 100)
};
}
return metadata;
}
/**
* Scan directories for plan documents
*/
async function scanForPlans() {
const plans = [];
for (const dir of SEARCH_DIRS) {
try {
const items = await fs.readdir(dir, { recursive: true, withFileTypes: true });
for (const item of items) {
if (!item.isFile()) continue;
const filename = item.name;
const filepath = path.join(item.path || dir, filename);
// Skip if matches exclusion patterns
if (EXCLUDE_PATTERNS.some(pattern => pattern.test(filepath))) {
continue;
}
const isPlan = PLAN_PATTERNS.some(pattern => pattern.test(filename));
if (isPlan) {
try {
const content = await fs.readFile(filepath, 'utf-8');
const stats = await fs.stat(filepath);
const metadata = parsePlanMetadata(content, filepath);
metadata.last_modified = stats.mtime.toISOString();
metadata.file_size = stats.size;
plans.push(metadata);
} catch (err) {
console.error(` ✗ Error reading ${filepath}:`, err.message);
}
}
}
} catch (err) {
// Directory might not exist, skip
continue;
}
}
return plans;
}
/**
* Calculate review urgency
*/
function calculateUrgency(plan) {
if (!plan.next_review) return 'unknown';
try {
const nextReview = new Date(plan.next_review);
const now = new Date();
const daysUntil = Math.ceil((nextReview - now) / (1000 * 60 * 60 * 24));
if (daysUntil < 0) return 'overdue';
if (daysUntil === 0) return 'today';
if (daysUntil <= 3) return 'this-week';
if (daysUntil <= 14) return 'soon';
return 'scheduled';
} catch (err) {
return 'unknown';
}
}
/**
* Determine plan health
*/
function assessPlanHealth(plan) {
const issues = [];
const now = new Date();
// Check if status is active but stale (>30 days since last modified)
if (plan.status && plan.status.toLowerCase().includes('active')) {
const lastMod = new Date(plan.last_modified);
const daysSinceUpdate = (now - lastMod) / (1000 * 60 * 60 * 24);
if (daysSinceUpdate > 30) {
issues.push(`Stale: No updates in ${Math.round(daysSinceUpdate)} days`);
}
}
// Check if completion is low but due date approaching
if (plan.completeness && plan.due) {
try {
const dueDate = new Date(plan.due);
const daysUntilDue = (dueDate - now) / (1000 * 60 * 60 * 24);
if (daysUntilDue < 14 && plan.completeness.percentage < 50) {
issues.push(`At risk: ${plan.completeness.percentage}% complete, due in ${Math.round(daysUntilDue)} days`);
}
} catch (err) {
// Invalid date, skip
}
}
// Check if review is overdue
const urgency = calculateUrgency(plan);
if (urgency === 'overdue') {
issues.push('Review overdue');
}
// Check if no owner assigned
if (!plan.owner || plan.owner.includes('TBD') || plan.owner.includes('assigned')) {
issues.push('No owner assigned');
}
// Overall health assessment
if (issues.length === 0) return { status: 'healthy', issues: [] };
if (issues.length === 1) return { status: 'attention', issues };
return { status: 'critical', issues };
}
/**
* Update plan registry
*/
async function updateRegistry(plans) {
// Deduplicate plans by filepath
const uniquePlans = [];
const seenPaths = new Set();
for (const plan of plans) {
if (!seenPaths.has(plan.filepath)) {
seenPaths.add(plan.filepath);
uniquePlans.push(plan);
}
}
const registry = {
version: '1.0.0',
last_scan: new Date().toISOString(),
total_plans: uniquePlans.length,
plans: uniquePlans.map(plan => ({
...plan,
urgency: calculateUrgency(plan),
health: assessPlanHealth(plan)
}))
};
await fs.writeFile(PLAN_REGISTRY, JSON.stringify(registry, null, 2));
return registry;
}
/**
* Display plan reminders
*/
function displayReminders(registry) {
console.log('═══════════════════════════════════════════════════════════');
console.log(' Plan Reminder System');
console.log('═══════════════════════════════════════════════════════════\n');
console.log(`Last Scan: ${new Date(registry.last_scan).toLocaleString()}`);
console.log(`Total Plans: ${registry.total_plans}\n`);
// Group plans by urgency
const overdue = registry.plans.filter(p => p.urgency === 'overdue');
const today = registry.plans.filter(p => p.urgency === 'today');
const thisWeek = registry.plans.filter(p => p.urgency === 'this-week');
const critical = registry.plans.filter(p => p.health.status === 'critical');
const attention = registry.plans.filter(p => p.health.status === 'attention');
// Display overdue reviews
if (overdue.length > 0) {
console.log('🔴 OVERDUE REVIEWS:');
overdue.forEach(plan => {
console.log(`${plan.title || plan.filename}`);
console.log(` Status: ${plan.status || 'Unknown'}`);
console.log(` Next Review: ${plan.next_review}`);
console.log(` File: ${path.relative(process.cwd(), plan.filepath)}`);
if (plan.health.issues.length > 0) {
plan.health.issues.forEach(issue => console.log(`${issue}`));
}
console.log('');
});
}
// Display today's reviews
if (today.length > 0) {
console.log('🟡 REVIEWS DUE TODAY:');
today.forEach(plan => {
console.log(`${plan.title || plan.filename}`);
console.log(` Status: ${plan.status || 'Unknown'}`);
console.log(` File: ${path.relative(process.cwd(), plan.filepath)}`);
console.log('');
});
}
// Display this week's reviews
if (thisWeek.length > 0) {
console.log('🟢 REVIEWS THIS WEEK:');
thisWeek.forEach(plan => {
console.log(`${plan.title || plan.filename}`);
console.log(` Next Review: ${plan.next_review}`);
console.log(` File: ${path.relative(process.cwd(), plan.filepath)}`);
console.log('');
});
}
// Display critical health issues
if (critical.length > 0) {
console.log('🚨 PLANS NEEDING ATTENTION:');
critical.forEach(plan => {
console.log(`${plan.title || plan.filename}`);
console.log(` Status: ${plan.status || 'Unknown'}`);
plan.health.issues.forEach(issue => console.log(`${issue}`));
console.log(` File: ${path.relative(process.cwd(), plan.filepath)}`);
console.log('');
});
}
// Display plans needing attention (not critical)
if (attention.length > 0 && critical.length === 0) {
console.log(' PLANS WITH MINOR ISSUES:');
attention.forEach(plan => {
console.log(`${plan.title || plan.filename}`);
plan.health.issues.forEach(issue => console.log(`${issue}`));
console.log('');
});
}
// Summary
console.log('');
console.log('SUMMARY:');
console.log(` Overdue Reviews: ${overdue.length}`);
console.log(` Due Today: ${today.length}`);
console.log(` Due This Week: ${thisWeek.length}`);
console.log(` Critical Health: ${critical.length}`);
console.log(` Needs Attention: ${attention.length}`);
console.log(` Healthy: ${registry.plans.filter(p => p.health.status === 'healthy').length}`);
console.log('\n');
// Recommendations
if (overdue.length > 0 || critical.length > 0) {
console.log('📌 RECOMMENDED ACTIONS:');
if (overdue.length > 0) {
console.log(' 1. Review overdue plans and update next_review dates');
}
if (critical.length > 0) {
console.log(' 2. Address critical health issues (stale plans, missing owners)');
}
console.log(' 3. Update plan status and completeness checkboxes');
console.log(' 4. Run this reminder weekly to stay on track\n');
}
}
/**
* List all plans
*/
function listAllPlans(registry, options = {}) {
console.log('\n📋 ALL TRACKED PLANS:\n');
const sortedPlans = [...registry.plans];
// Sort by status priority
const statusPriority = {
'active': 1,
'in progress': 2,
'pending': 3,
'on hold': 4,
'completed': 5,
'cancelled': 6
};
sortedPlans.sort((a, b) => {
const aStatus = (a.status || '').toLowerCase();
const bStatus = (b.status || '').toLowerCase();
const aPriority = statusPriority[aStatus] || 99;
const bPriority = statusPriority[bStatus] || 99;
return aPriority - bPriority;
});
sortedPlans.forEach((plan, index) => {
console.log(`${index + 1}. ${plan.title || plan.filename}`);
console.log(` Status: ${plan.status || 'Unknown'} | Priority: ${plan.priority || 'Unknown'}`);
if (plan.completeness) {
console.log(` Completeness: ${plan.completeness.completed}/${plan.completeness.total} (${plan.completeness.percentage}%)`);
}
if (plan.next_review) {
console.log(` Next Review: ${plan.next_review} (${plan.urgency})`);
}
if (plan.owner) {
console.log(` Owner: ${plan.owner}`);
}
if (plan.health.status !== 'healthy') {
console.log(` Health: ${plan.health.status}`);
if (options.verbose) {
plan.health.issues.forEach(issue => console.log(` • ${issue}`));
}
}
console.log(` File: ${path.relative(process.cwd(), plan.filepath)}`);
console.log('');
});
}
/**
* Main execution
*/
async function main() {
const args = process.argv.slice(2);
const command = args[0] || 'remind';
try {
console.log('\nScanning for plan documents...');
const plans = await scanForPlans();
console.log(`✓ Found ${plans.length} plan documents\n`);
const registry = await updateRegistry(plans);
if (command === 'list') {
listAllPlans(registry, { verbose: args.includes('--verbose') });
} else {
displayReminders(registry);
}
// Exit with code based on urgency
if (registry.plans.some(p => p.urgency === 'overdue')) {
process.exit(1); // Overdue plans found
}
} catch (error) {
console.error('\n Error:', error.message);
if (args.includes('--verbose')) {
console.error(error.stack);
}
process.exit(1);
}
}
// Run if called directly
if (require.main === module) {
main();
}
module.exports = { scanForPlans, updateRegistry, calculateUrgency, assessPlanHealth };