mcp-ynab
Version:
Model Context Protocol server for YNAB integration
545 lines (471 loc) • 20.9 kB
JavaScript
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 };