- 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>
457 lines
14 KiB
JavaScript
457 lines
14 KiB
JavaScript
/**
|
||
* 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 };
|