UNPKG

mcp-ynab

Version:

Model Context Protocol server for YNAB integration

545 lines (471 loc) 20.9 kB
import { YnabClient } from '../../shared/ynab-client.js'; import { CacheManager } from '../../shared/cache-manager.js'; import { ErrorHandler } from '../../shared/error-handler.js'; const cache = new CacheManager(); const errorHandler = new ErrorHandler(); const toolDefinition = { name: 'get_goals_status', description: 'Analyze progress on savings goals and targets with completion timelines, funding rates, and optimization recommendations', inputSchema: { type: 'object', properties: { budget_id: { type: 'string', description: 'Specific budget ID (optional, defaults to default budget)' }, include_completed: { type: 'boolean', description: 'Include completed goals in analysis (default: false)', default: false }, goal_type_filter: { type: 'string', enum: ['target_category_balance', 'target_category_balance_by_date', 'monthly_funding', 'plan_your_spending'], description: 'Filter by specific goal type (optional)' }, sort_by: { type: 'string', enum: ['progress_percentage', 'target_date', 'target_amount', 'name', 'time_to_complete'], description: 'Sort goals by field (default: progress_percentage)', default: 'progress_percentage' }, sort_order: { type: 'string', enum: ['asc', 'desc'], description: 'Sort order (default: desc for progress, asc for dates/names)', default: 'desc' }, funding_analysis_months: { type: 'integer', description: 'Number of months to analyze for funding rate trends (default: 6, max: 12)', minimum: 1, maximum: 12, default: 6 } }, additionalProperties: false } }; const handler = async (params) => { try { const ynab = new YnabClient(); const { budget_id, include_completed = false, goal_type_filter, sort_by = 'progress_percentage', sort_order = 'desc', funding_analysis_months = 6 } = params; // Generate cache key const cacheKey = cache.generateKey('goals_status', params); return await cache.getOrSet(cacheKey, async () => { const budgetId = budget_id || await ynab.getDefaultBudgetId(); // Get current categories with goals const categoryGroups = await ynab.getCategories(budgetId); // Get historical data for funding analysis const endDate = new Date(); const startDate = new Date(); startDate.setMonth(endDate.getMonth() - funding_analysis_months); const historicalMonths = []; for (let i = 0; i < funding_analysis_months; i++) { const monthDate = new Date(); monthDate.setMonth(endDate.getMonth() - i); const monthString = `${monthDate.getFullYear()}-${String(monthDate.getMonth() + 1).padStart(2, '0')}-01`; historicalMonths.push(monthString); } // Get historical category data for funding analysis const historicalData = await Promise.all( historicalMonths.map(async (month) => { try { const categories = await ynab.getCategories(budgetId, month); return { month, categories }; } catch (error) { return { month, categories: [] }; } }) ); // Extract categories with goals const goalsData = []; categoryGroups.forEach(group => { if (group.categories) { group.categories.forEach(category => { // Skip if no goal or hidden/deleted if (!category.goal_type || category.goal_type === 'none' || category.hidden || category.deleted) { return; } // Skip completed goals unless requested const isCompleted = isGoalCompleted(category); if (!include_completed && isCompleted) { return; } // Filter by goal type if specified if (goal_type_filter && category.goal_type !== goal_type_filter) { return; } // Calculate goal analysis const goalAnalysis = analyzeGoal(category, historicalData); goalsData.push({ id: category.id, name: category.name, group_name: group.name, goal_type: category.goal_type, goal_target: category.goal_target ? ynab.milliunitsToAmount(category.goal_target) : null, goal_target_month: category.goal_target_month || null, goal_percentage_complete: category.goal_percentage_complete || 0, current_balance: ynab.milliunitsToAmount(category.balance || 0), is_completed: isCompleted, ...goalAnalysis }); }); } }); // Sort goals goalsData.sort((a, b) => { let aValue, bValue; switch (sort_by) { case 'progress_percentage': aValue = a.goal_percentage_complete; bValue = b.goal_percentage_complete; break; case 'target_date': aValue = a.goal_target_month ? new Date(a.goal_target_month) : new Date('2099-12-31'); bValue = b.goal_target_month ? new Date(b.goal_target_month) : new Date('2099-12-31'); break; case 'target_amount': aValue = a.goal_target || 0; bValue = b.goal_target || 0; break; case 'name': aValue = a.name.toLowerCase(); bValue = b.name.toLowerCase(); break; case 'time_to_complete': aValue = a.estimated_months_to_complete || 999; bValue = b.estimated_months_to_complete || 999; break; default: aValue = a.goal_percentage_complete; bValue = b.goal_percentage_complete; } // Determine sort order let actualOrder = sort_order; if (sort_order === 'desc' && ['target_date', 'name', 'time_to_complete'].includes(sort_by)) { actualOrder = 'asc'; // These fields default to ascending } if (actualOrder === 'desc') { return aValue < bValue ? 1 : aValue > bValue ? -1 : 0; } else { return aValue > bValue ? 1 : aValue < bValue ? -1 : 0; } }); // Calculate summary statistics const totalGoals = goalsData.length; const completedGoals = goalsData.filter(g => g.is_completed).length; const activeGoals = totalGoals - completedGoals; const totalTargetAmount = goalsData.reduce((sum, g) => sum + (g.goal_target || 0), 0); const totalCurrentAmount = goalsData.reduce((sum, g) => sum + g.current_balance, 0); const overallProgress = totalTargetAmount > 0 ? (totalCurrentAmount / totalTargetAmount) * 100 : 0; // Goals by status const onTrackGoals = goalsData.filter(g => !g.is_completed && g.funding_status === 'on_track').length; const behindGoals = goalsData.filter(g => !g.is_completed && g.funding_status === 'behind').length; const aheadGoals = goalsData.filter(g => !g.is_completed && g.funding_status === 'ahead').length; // Monthly funding requirements const monthlyFundingRequired = goalsData .filter(g => !g.is_completed && g.recommended_monthly_funding) .reduce((sum, g) => sum + g.recommended_monthly_funding, 0); // Identify goals needing attention const goalsNeedingAttention = goalsData .filter(g => !g.is_completed && ( g.funding_status === 'behind' || (g.goal_target_month && isApproachingDeadline(g.goal_target_month, g.goal_percentage_complete)) )) .slice(0, 5); // Top 5 priority goals // Group goals by type for analysis const goalsByType = {}; goalsData.forEach(goal => { if (!goalsByType[goal.goal_type]) { goalsByType[goal.goal_type] = { type: goal.goal_type, count: 0, total_target: 0, total_current: 0, avg_progress: 0 }; } const typeData = goalsByType[goal.goal_type]; typeData.count++; typeData.total_target += goal.goal_target || 0; typeData.total_current += goal.current_balance; }); // Calculate averages Object.values(goalsByType).forEach(typeData => { typeData.avg_progress = typeData.total_target > 0 ? (typeData.total_current / typeData.total_target) * 100 : 0; }); return { goals: goalsData, summary: { total_goals: totalGoals, active_goals: activeGoals, completed_goals: completedGoals, total_target_amount: totalTargetAmount, total_current_amount: totalCurrentAmount, overall_progress_percentage: overallProgress, completion_rate: totalGoals > 0 ? (completedGoals / totalGoals) * 100 : 0 }, funding_status: { on_track: onTrackGoals, behind: behindGoals, ahead: aheadGoals, monthly_funding_required: monthlyFundingRequired }, goals_by_type: Object.values(goalsByType), priority_goals: goalsNeedingAttention, insights: generateGoalsInsights(goalsData, overallProgress, monthlyFundingRequired), recommendations: generateGoalsRecommendations(goalsData, goalsNeedingAttention), analysis_period: { funding_analysis_months: funding_analysis_months, start_date: startDate.toISOString().split('T')[0], end_date: endDate.toISOString().split('T')[0] }, filters_applied: { budget_id: budgetId, include_completed, goal_type_filter: goal_type_filter || null, sort_by, sort_order, funding_analysis_months } }; }, 10); // Cache for 10 minutes } catch (error) { return errorHandler.formatForMCP(error); } }; // Helper function to check if goal is completed function isGoalCompleted(category) { return category.goal_percentage_complete >= 100 || (category.goal_target && category.balance >= category.goal_target); } // Helper function to analyze individual goal function analyzeGoal(category, historicalData) { const currentBalance = category.balance / 1000; const targetAmount = category.goal_target ? category.goal_target / 1000 : null; const targetDate = category.goal_target_month; const progressPercentage = category.goal_percentage_complete || 0; // Calculate funding trend const fundingTrend = calculateFundingTrend(category.id, historicalData); // Estimate completion timeline let estimatedMonthsToComplete = null; let fundingStatus = 'unknown'; let recommendedMonthlyFunding = null; if (targetAmount && targetAmount > currentBalance) { const remainingAmount = targetAmount - currentBalance; if (fundingTrend.average_monthly_funding > 0) { estimatedMonthsToComplete = Math.ceil(remainingAmount / fundingTrend.average_monthly_funding); // Determine funding status based on target date if (targetDate) { const targetDateObj = new Date(targetDate); const monthsUntilTarget = Math.max(1, (targetDateObj.getFullYear() - new Date().getFullYear()) * 12 + (targetDateObj.getMonth() - new Date().getMonth()) ); const requiredMonthlyFunding = remainingAmount / monthsUntilTarget; recommendedMonthlyFunding = requiredMonthlyFunding; if (fundingTrend.average_monthly_funding >= requiredMonthlyFunding * 0.9) { fundingStatus = 'on_track'; } else if (fundingTrend.average_monthly_funding >= requiredMonthlyFunding * 0.7) { fundingStatus = 'slightly_behind'; } else { fundingStatus = 'behind'; } if (fundingTrend.average_monthly_funding > requiredMonthlyFunding * 1.1) { fundingStatus = 'ahead'; } } else { // No target date, just assess based on funding trend if (fundingTrend.trend === 'increasing') { fundingStatus = 'improving'; } else if (fundingTrend.trend === 'decreasing') { fundingStatus = 'declining'; } else { fundingStatus = 'stable'; } } } else { fundingStatus = 'no_funding'; if (targetDate) { const targetDateObj = new Date(targetDate); const monthsUntilTarget = Math.max(1, (targetDateObj.getFullYear() - new Date().getFullYear()) * 12 + (targetDateObj.getMonth() - new Date().getMonth()) ); recommendedMonthlyFunding = remainingAmount / monthsUntilTarget; } } } // Calculate projected completion date let projectedCompletionDate = null; if (estimatedMonthsToComplete && estimatedMonthsToComplete < 240) { // Reasonable limit const projectedDate = new Date(); projectedDate.setMonth(projectedDate.getMonth() + estimatedMonthsToComplete); projectedCompletionDate = projectedDate.toISOString().split('T')[0]; } return { funding_trend: fundingTrend, funding_status: fundingStatus, estimated_months_to_complete: estimatedMonthsToComplete, projected_completion_date: projectedCompletionDate, recommended_monthly_funding: recommendedMonthlyFunding, remaining_amount: targetAmount ? Math.max(0, targetAmount - currentBalance) : null, days_until_target: targetDate ? Math.max(0, Math.ceil((new Date(targetDate) - new Date()) / (1000 * 60 * 60 * 24))) : null }; } // Helper function to calculate funding trend function calculateFundingTrend(categoryId, historicalData) { const monthlyFunding = []; // Track balance changes month over month for (let i = historicalData.length - 1; i > 0; i--) { const currentMonth = historicalData[i]; const previousMonth = historicalData[i - 1]; const currentCategory = findCategoryInData(categoryId, currentMonth.categories); const previousCategory = findCategoryInData(categoryId, previousMonth.categories); if (currentCategory && previousCategory) { const currentBalance = currentCategory.balance / 1000; const previousBalance = previousCategory.balance / 1000; const monthlyChange = currentBalance - previousBalance; monthlyFunding.push({ month: currentMonth.month, balance: currentBalance, funding: Math.max(0, monthlyChange) // Only count positive changes as funding }); } } if (monthlyFunding.length === 0) { return { average_monthly_funding: 0, trend: 'unknown', consistency: 0, months_analyzed: 0 }; } const averageFunding = monthlyFunding.reduce((sum, m) => sum + m.funding, 0) / monthlyFunding.length; // Calculate trend let trend = 'stable'; if (monthlyFunding.length >= 3) { const recentAvg = monthlyFunding.slice(-2).reduce((sum, m) => sum + m.funding, 0) / 2; const olderAvg = monthlyFunding.slice(0, -2).reduce((sum, m) => sum + m.funding, 0) / Math.max(monthlyFunding.length - 2, 1); if (recentAvg > olderAvg * 1.2) { trend = 'increasing'; } else if (recentAvg < olderAvg * 0.8) { trend = 'decreasing'; } } // Calculate consistency (percentage of months with funding) const monthsWithFunding = monthlyFunding.filter(m => m.funding > 0).length; const consistency = (monthsWithFunding / monthlyFunding.length) * 100; return { average_monthly_funding: averageFunding, trend: trend, consistency: consistency, months_analyzed: monthlyFunding.length, monthly_data: monthlyFunding }; } // Helper function to find category in historical data function findCategoryInData(categoryId, categoryGroups) { for (const group of categoryGroups) { if (group.categories) { const category = group.categories.find(cat => cat.id === categoryId); if (category) return category; } } return null; } // Helper function to check if approaching deadline function isApproachingDeadline(targetDate, progressPercentage) { const target = new Date(targetDate); const now = new Date(); const daysUntilTarget = Math.ceil((target - now) / (1000 * 60 * 60 * 24)); // Consider approaching if less than 60 days and less than 75% complete return daysUntilTarget < 60 && progressPercentage < 75; } // Helper function to generate insights function generateGoalsInsights(goalsData, overallProgress, monthlyFundingRequired) { const insights = []; // Overall progress insights if (overallProgress >= 80) { insights.push('Excellent goal progress - most targets are nearly achieved'); } else if (overallProgress >= 60) { insights.push('Good goal progress - majority of targets are on track'); } else if (overallProgress >= 40) { insights.push('Moderate goal progress - some targets may need increased funding'); } else { insights.push('Low goal progress - review funding strategy and priorities'); } // Funding insights if (monthlyFundingRequired > 0) { insights.push(`Total monthly funding needed: $${monthlyFundingRequired.toFixed(2)} to stay on track`); } // Status distribution insights const activeGoals = goalsData.filter(g => !g.is_completed); const behindGoals = activeGoals.filter(g => g.funding_status === 'behind').length; const onTrackGoals = activeGoals.filter(g => g.funding_status === 'on_track').length; if (behindGoals > onTrackGoals && behindGoals > 0) { insights.push(`${behindGoals} goals are behind schedule - consider reallocating budget`); } else if (onTrackGoals > 0) { insights.push(`${onTrackGoals} goals are on track - maintain current funding levels`); } // Completion insights const nearCompletion = goalsData.filter(g => !g.is_completed && g.goal_percentage_complete >= 90).length; if (nearCompletion > 0) { insights.push(`${nearCompletion} goals are very close to completion`); } return insights; } // Helper function to generate recommendations function generateGoalsRecommendations(goalsData, priorityGoals) { const recommendations = []; // Priority goal recommendations if (priorityGoals.length > 0) { recommendations.push(`Focus on ${priorityGoals.length} priority goals that need immediate attention`); const highestPriority = priorityGoals[0]; if (highestPriority.recommended_monthly_funding) { recommendations.push(`Increase funding for "${highestPriority.name}" to $${highestPriority.recommended_monthly_funding.toFixed(2)}/month`); } } // Funding strategy recommendations const noFundingGoals = goalsData.filter(g => !g.is_completed && g.funding_status === 'no_funding').length; if (noFundingGoals > 0) { recommendations.push(`${noFundingGoals} goals have no recent funding - review if these are still priorities`); } // Completion recommendations const nearCompletion = goalsData.filter(g => !g.is_completed && g.goal_percentage_complete >= 95); if (nearCompletion.length > 0) { recommendations.push(`Complete ${nearCompletion.length} goals that are 95%+ funded for quick wins`); } // Deadline recommendations const urgentGoals = goalsData.filter(g => !g.is_completed && g.days_until_target && g.days_until_target < 30 && g.goal_percentage_complete < 90 ); if (urgentGoals.length > 0) { recommendations.push(`${urgentGoals.length} goals have approaching deadlines - consider accelerated funding`); } // Optimization recommendations const consistentGoals = goalsData.filter(g => g.funding_trend && g.funding_trend.consistency > 80 && g.funding_status === 'on_track' ).length; if (consistentGoals > 0) { recommendations.push(`${consistentGoals} goals have consistent funding - consider automating these transfers`); } if (recommendations.length === 0) { recommendations.push('Goals appear well-managed - continue current funding strategy and monitor progress'); } return recommendations; } export { toolDefinition, handler };