Cleanup
This commit is contained in:
parent
fc2d9ed56b
commit
983aeca2dc
32 changed files with 1570 additions and 27266 deletions
BIN
components/.DS_Store
vendored
Normal file
BIN
components/.DS_Store
vendored
Normal file
Binary file not shown.
339
components/AnnualBudget.vue
Normal file
339
components/AnnualBudget.vue
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
<template>
|
||||
<div class="space-y-8">
|
||||
<!-- Annual Budget Overview -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-2xl font-bold">Annual Budget Overview</h2>
|
||||
|
||||
<div class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="border-b-2 border-black bg-gray-100">
|
||||
<th class="border-r-2 border-black px-4 py-3 text-left font-bold">Category</th>
|
||||
<th class="border-r border-gray-400 px-4 py-3 text-right font-bold">Planned</th>
|
||||
<th class="px-4 py-3 text-right font-bold">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Revenue Section -->
|
||||
<tr class="bg-black text-white">
|
||||
<td class="px-4 py-2 font-bold" colspan="3">REVENUE</td>
|
||||
</tr>
|
||||
|
||||
<!-- Revenue Categories -->
|
||||
<tr v-for="(category, index) in revenueCategories"
|
||||
:key="`rev-${index}`"
|
||||
class="border-t border-gray-200"
|
||||
v-show="category.planned > 0">
|
||||
<td class="border-r-2 border-black px-4 py-2">{{ category.name }}</td>
|
||||
<td class="border-r border-gray-400 px-4 py-2 text-right">
|
||||
{{ formatCurrency(category.planned) }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
{{ category.percentage }}%
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Total Revenue -->
|
||||
<tr class="border-t-2 border-black font-semibold bg-gray-50">
|
||||
<td class="border-r-2 border-black px-4 py-2">Total Revenue</td>
|
||||
<td class="border-r border-gray-400 px-4 py-2 text-right">
|
||||
{{ formatCurrency(totalRevenuePlanned) }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right">100%</td>
|
||||
</tr>
|
||||
|
||||
<!-- Revenue Diversification Guidance -->
|
||||
<tr :class="guidanceBackgroundClass">
|
||||
<td colspan="3" class="border-t border-gray-300 px-4 py-3">
|
||||
<div class="text-sm">
|
||||
<p class="font-medium mb-2">{{ diversificationGuidance }}</p>
|
||||
<p class="text-gray-600 mb-2" v-if="suggestedCategories.length > 0">
|
||||
Consider developing: {{ suggestedCategories.join(', ') }}
|
||||
</p>
|
||||
<p class="text-xs">
|
||||
<NuxtLink
|
||||
to="/help#revenue-diversification"
|
||||
class="text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
Learn how to develop these revenue streams →
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Spacer -->
|
||||
<tr>
|
||||
<td colspan="3" class="h-2"></td>
|
||||
</tr>
|
||||
|
||||
<!-- Expenses Section -->
|
||||
<tr class="bg-black text-white">
|
||||
<td class="px-4 py-2 font-bold" colspan="3">EXPENSES</td>
|
||||
</tr>
|
||||
|
||||
<!-- Expense Categories -->
|
||||
<tr v-for="(category, index) in expenseCategories"
|
||||
:key="`exp-${index}`"
|
||||
class="border-t border-gray-200"
|
||||
v-show="category.planned > 0">
|
||||
<td class="border-r-2 border-black px-4 py-2">{{ category.name }}</td>
|
||||
<td class="border-r border-gray-400 px-4 py-2 text-right">
|
||||
{{ formatCurrency(category.planned) }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
{{ category.percentage }}%
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Total Expenses -->
|
||||
<tr class="border-t-2 border-black font-semibold bg-gray-50">
|
||||
<td class="border-r-2 border-black px-4 py-2">Total Expenses</td>
|
||||
<td class="border-r border-gray-400 px-4 py-2 text-right">
|
||||
{{ formatCurrency(totalExpensesPlanned) }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right">100%</td>
|
||||
</tr>
|
||||
|
||||
<!-- Net Total -->
|
||||
<tr class="border-t-2 border-black font-bold text-lg" :class="netTotalClass">
|
||||
<td class="border-r-2 border-black px-4 py-3">NET TOTAL</td>
|
||||
<td class="border-r border-gray-400 px-4 py-3 text-right">
|
||||
{{ formatCurrency(netTotal) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">-</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
orgId: string;
|
||||
year: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
year: () => new Date().getFullYear()
|
||||
});
|
||||
|
||||
// Get budget data from store
|
||||
const budgetStore = useBudgetStore();
|
||||
|
||||
// Revenue categories with calculations
|
||||
const revenueCategories = computed(() => {
|
||||
const categories = [
|
||||
{ key: 'gamesProducts', name: 'Games & Products', planned: 0, percentage: 0 },
|
||||
{ key: 'servicesContracts', name: 'Services & Contracts', planned: 0, percentage: 0 },
|
||||
{ key: 'grantsFunding', name: 'Grants & Funding', planned: 0, percentage: 0 },
|
||||
{ key: 'communitySupport', name: 'Community Support', planned: 0, percentage: 0 },
|
||||
{ key: 'partnerships', name: 'Partnerships', planned: 0, percentage: 0 },
|
||||
{ key: 'investmentIncome', name: 'Investment Income', planned: 0, percentage: 0 },
|
||||
{ key: 'inKindContributions', name: 'In-Kind Contributions', planned: 0, percentage: 0 },
|
||||
];
|
||||
|
||||
// Calculate planned amounts for each category
|
||||
budgetStore.budgetWorksheet.revenue.forEach(item => {
|
||||
const annualPlanned = Object.values(item.monthlyValues || {}).reduce((sum, val) => sum + (val || 0), 0);
|
||||
const categoryIndex = categories.findIndex(cat => cat.name === item.mainCategory);
|
||||
if (categoryIndex !== -1) {
|
||||
categories[categoryIndex].planned += annualPlanned;
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate percentages
|
||||
const total = categories.reduce((sum, cat) => sum + cat.planned, 0);
|
||||
categories.forEach(cat => {
|
||||
cat.percentage = total > 0 ? Math.round((cat.planned / total) * 100) : 0;
|
||||
});
|
||||
|
||||
return categories;
|
||||
});
|
||||
|
||||
|
||||
// Expense categories with calculations
|
||||
const expenseCategories = computed(() => {
|
||||
const categories = [
|
||||
{ name: 'Salaries & Benefits', planned: 0, percentage: 0 },
|
||||
{ name: 'Development Costs', planned: 0, percentage: 0 },
|
||||
{ name: 'Equipment & Technology', planned: 0, percentage: 0 },
|
||||
{ name: 'Marketing & Outreach', planned: 0, percentage: 0 },
|
||||
{ name: 'Office & Operations', planned: 0, percentage: 0 },
|
||||
{ name: 'Legal & Professional', planned: 0, percentage: 0 },
|
||||
{ name: 'Other Expenses', planned: 0, percentage: 0 },
|
||||
];
|
||||
|
||||
// Calculate planned amounts for each category
|
||||
budgetStore.budgetWorksheet.expenses.forEach(item => {
|
||||
const annualPlanned = Object.values(item.monthlyValues || {}).reduce((sum, val) => sum + (val || 0), 0);
|
||||
const categoryIndex = categories.findIndex(cat => cat.name === item.mainCategory);
|
||||
if (categoryIndex !== -1) {
|
||||
categories[categoryIndex].planned += annualPlanned;
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate percentages
|
||||
const total = categories.reduce((sum, cat) => sum + cat.planned, 0);
|
||||
categories.forEach(cat => {
|
||||
cat.percentage = total > 0 ? Math.round((cat.planned / total) * 100) : 0;
|
||||
});
|
||||
|
||||
return categories;
|
||||
});
|
||||
|
||||
// Totals
|
||||
const totalRevenuePlanned = computed(() =>
|
||||
revenueCategories.value.reduce((sum, cat) => sum + cat.planned, 0)
|
||||
);
|
||||
|
||||
const totalExpensesPlanned = computed(() =>
|
||||
expenseCategories.value.reduce((sum, cat) => sum + cat.planned, 0)
|
||||
);
|
||||
|
||||
const netTotal = computed(() =>
|
||||
totalRevenuePlanned.value - totalExpensesPlanned.value
|
||||
);
|
||||
|
||||
const netTotalClass = computed(() => {
|
||||
if (netTotal.value > 0) return 'bg-green-50';
|
||||
if (netTotal.value < 0) return 'bg-red-50';
|
||||
return 'bg-gray-50';
|
||||
});
|
||||
|
||||
|
||||
// Diversification guidance
|
||||
const diversificationGuidance = computed(() => {
|
||||
const categoriesWithRevenue = revenueCategories.value.filter(cat => cat.percentage > 0);
|
||||
const topCategory = categoriesWithRevenue.reduce((max, cat) => cat.percentage > max.percentage ? cat : max, { percentage: 0, name: '' });
|
||||
const categoriesAbove20 = categoriesWithRevenue.filter(cat => cat.percentage >= 20).length;
|
||||
|
||||
let guidance = "";
|
||||
|
||||
// Concentration Risk
|
||||
if (topCategory.percentage >= 70) {
|
||||
guidance += `Very high concentration risk: most of your revenue is from ${topCategory.name} (${topCategory.percentage}%). `;
|
||||
} else if (topCategory.percentage >= 50) {
|
||||
guidance += `High concentration risk: ${topCategory.name} makes up ${topCategory.percentage}% of your revenue. `;
|
||||
} else {
|
||||
guidance += "No single category dominates your revenue. ";
|
||||
}
|
||||
|
||||
// Diversification Benchmark
|
||||
if (categoriesAbove20 >= 3) {
|
||||
guidance += "Your mix is reasonably balanced across multiple sources.";
|
||||
} else if (categoriesAbove20 === 2) {
|
||||
guidance += "Your mix is split, but still reliant on just two sources.";
|
||||
} else {
|
||||
guidance += "Your revenue is concentrated; aim to grow at least 2–3 other categories.";
|
||||
}
|
||||
|
||||
// Optional Positive Nudges
|
||||
const grantsCategory = categoriesWithRevenue.find(cat => cat.name === 'Grants & Funding');
|
||||
const servicesCategory = categoriesWithRevenue.find(cat => cat.name === 'Services & Contracts');
|
||||
const productsCategory = categoriesWithRevenue.find(cat => cat.name === 'Games & Products');
|
||||
|
||||
if (grantsCategory && grantsCategory.percentage >= 20) {
|
||||
guidance += " You've secured meaningful support from grants — consider pairing this with services or product revenue for stability.";
|
||||
} else if (servicesCategory && servicesCategory.percentage >= 20 && productsCategory && productsCategory.percentage >= 20) {
|
||||
guidance += " Strong foundation in both services and products — this balance helps smooth cash flow.";
|
||||
}
|
||||
|
||||
return guidance;
|
||||
});
|
||||
|
||||
const guidanceBackgroundClass = computed(() => {
|
||||
const topCategory = revenueCategories.value.reduce((max, cat) => cat.percentage > max.percentage ? cat : max, { percentage: 0 });
|
||||
|
||||
if (topCategory.percentage >= 70) {
|
||||
return 'bg-red-50';
|
||||
} else if (topCategory.percentage >= 50) {
|
||||
return 'bg-red-50';
|
||||
} else {
|
||||
const categoriesAbove20 = revenueCategories.value.filter(cat => cat.percentage >= 20).length;
|
||||
if (categoriesAbove20 >= 3) {
|
||||
return 'bg-green-50';
|
||||
} else {
|
||||
return 'bg-yellow-50';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Suggested categories to develop
|
||||
const suggestedCategories = computed(() => {
|
||||
const categoriesWithRevenue = revenueCategories.value.filter(cat => cat.percentage > 0);
|
||||
const categoriesWithoutRevenue = revenueCategories.value.filter(cat => cat.percentage === 0);
|
||||
const categoriesAbove20 = categoriesWithRevenue.filter(cat => cat.percentage >= 20).length;
|
||||
|
||||
// If we have fewer than 3 categories above 20%, suggest developing others
|
||||
if (categoriesAbove20 < 3) {
|
||||
// Prioritize categories that complement existing strengths
|
||||
const suggestions = [];
|
||||
|
||||
// If they have services, suggest products for balance
|
||||
if (categoriesWithRevenue.some(cat => cat.name === 'Services & Contracts') &&
|
||||
!categoriesWithRevenue.some(cat => cat.name === 'Games & Products')) {
|
||||
suggestions.push('Games & Products');
|
||||
}
|
||||
|
||||
// If they have products, suggest services for stability
|
||||
if (categoriesWithRevenue.some(cat => cat.name === 'Games & Products') &&
|
||||
!categoriesWithRevenue.some(cat => cat.name === 'Services & Contracts')) {
|
||||
suggestions.push('Services & Contracts');
|
||||
}
|
||||
|
||||
// Always suggest grants if not present
|
||||
if (!categoriesWithRevenue.some(cat => cat.name === 'Grants & Funding')) {
|
||||
suggestions.push('Grants & Funding');
|
||||
}
|
||||
|
||||
// Add community support for stability
|
||||
if (!categoriesWithRevenue.some(cat => cat.name === 'Community Support')) {
|
||||
suggestions.push('Community Support');
|
||||
}
|
||||
|
||||
return suggestions.slice(0, 3); // Limit to 3 suggestions
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
// Utility functions
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount || 0);
|
||||
}
|
||||
|
||||
function getPercentageClass(percentage: number): string {
|
||||
if (percentage > 50) return 'text-red-600 font-bold';
|
||||
if (percentage > 35) return 'text-yellow-600 font-semibold';
|
||||
if (percentage > 20) return 'text-black font-medium';
|
||||
return 'text-gray-500';
|
||||
}
|
||||
|
||||
|
||||
// Initialize
|
||||
onMounted(() => {
|
||||
console.log(`Annual budget view for org: ${props.orgId}, year: ${props.year}`);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Remove number input spinners */
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -46,6 +46,11 @@ const coopBuilderItems = [
|
|||
name: "Revenue Mix",
|
||||
path: "/mix",
|
||||
},
|
||||
{
|
||||
id: "runway-lite",
|
||||
name: "Runway Lite",
|
||||
path: "/runway-lite",
|
||||
},
|
||||
{
|
||||
id: "scenarios",
|
||||
name: "Scenarios",
|
||||
|
|
|
|||
513
components/RunwayLite.vue
Normal file
513
components/RunwayLite.vue
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
<template>
|
||||
<div class="section-card">
|
||||
<!-- Main Headline -->
|
||||
<div class="text-center mb-6" v-if="hasData">
|
||||
<div class="text-3xl font-mono font-bold text-black dark:text-white mb-2">
|
||||
{{ mainHeadline }}
|
||||
</div>
|
||||
<div class="text-lg font-mono text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{{ subHeadline }}
|
||||
</div>
|
||||
|
||||
<!-- Coverage Text -->
|
||||
<div class="text-base font-mono text-neutral-700 dark:text-neutral-300 mb-4" v-if="coverageText">
|
||||
{{ coverageText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- Toggles (Experiments) -->
|
||||
<div class="mb-6 space-y-3" v-if="hasData">
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
v-model="includePlannedRevenue"
|
||||
type="checkbox"
|
||||
class="bitmap-checkbox"
|
||||
>
|
||||
<span class="text-sm font-mono font-bold text-black dark:text-white">Count planned income</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
v-model="imagineNoIncome"
|
||||
type="checkbox"
|
||||
class="bitmap-checkbox"
|
||||
>
|
||||
<span class="text-sm font-mono font-bold text-black dark:text-white">Imagine no income</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Chart Container -->
|
||||
<div class="mb-6">
|
||||
<div class="h-48 relative bg-white dark:bg-neutral-950 border border-black dark:border-white">
|
||||
<canvas
|
||||
ref="chartCanvas"
|
||||
class="w-full h-full"
|
||||
width="400"
|
||||
height="192"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart Caption -->
|
||||
<div class="text-center text-sm font-mono text-neutral-600 dark:text-neutral-400 mb-4" v-if="hasData">
|
||||
This shows how your coop's money might hold up over a year.
|
||||
</div>
|
||||
|
||||
<!-- Guidance Sentence -->
|
||||
<div class="text-center mb-4" v-if="hasData">
|
||||
<div class="text-base font-mono text-neutral-700 dark:text-neutral-300">
|
||||
{{ guidanceText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diversification Risk -->
|
||||
<div v-if="diversificationGuidance" class="text-center text-sm font-mono text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{{ diversificationGuidance }}
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-8">
|
||||
<div class="text-neutral-400 mb-4">
|
||||
<svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="font-mono text-neutral-500 dark:text-neutral-500 text-sm">
|
||||
Complete the Setup Wizard to see your runway projection
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
startingCash?: number
|
||||
revenuePlanned?: number[]
|
||||
expensePlanned?: number[]
|
||||
members?: Array<{ name: string, needs: number, targetPay: number, payRelationship: string }>
|
||||
diversificationGuidance?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
startingCash: 0,
|
||||
revenuePlanned: () => [],
|
||||
expensePlanned: () => [],
|
||||
members: () => [],
|
||||
diversificationGuidance: ''
|
||||
})
|
||||
|
||||
const includePlannedRevenue = ref(true)
|
||||
const imagineNoIncome = ref(false)
|
||||
const chartCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
const months = [...Array(12).keys()] // 0..11
|
||||
const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
|
||||
const targetMonths = 6
|
||||
const horizon = 6
|
||||
|
||||
const toNum = (v:any) => Number.isFinite(+v) ? +v : 0
|
||||
|
||||
const monthlyCosts = computed(() => {
|
||||
if (!Array.isArray(props.expensePlanned)) return 0
|
||||
const sum = props.expensePlanned.reduce((a, b) => toNum(a) + toNum(b), 0)
|
||||
return sum / 12
|
||||
})
|
||||
|
||||
// Keep the old name for compatibility
|
||||
const monthlyBurn = monthlyCosts
|
||||
|
||||
const fmtCurrency = (v:number) => Number.isFinite(v) ? new Intl.NumberFormat(undefined,{style:'currency',currency:'USD',maximumFractionDigits:0}).format(v) : ''
|
||||
const fmtShort = (v:number) => {
|
||||
if (!Number.isFinite(v)) return ''
|
||||
if (Math.abs(v) >= 1000) return `${(v/1000).toFixed(0)}k`
|
||||
return `${Math.round(v)}`
|
||||
}
|
||||
|
||||
function outOfCashMonth(balances: number[]): number {
|
||||
return balances.findIndex(b => b < 0) // -1 if none
|
||||
}
|
||||
|
||||
// Pay coverage calculations
|
||||
const memberCoverage = computed(() => {
|
||||
if (!Array.isArray(props.members) || props.members.length === 0) return []
|
||||
|
||||
return props.members.map(member => {
|
||||
const coverage = member.needs > 0 ? ((member.targetPay || 0) / member.needs) * 100 : 0
|
||||
return {
|
||||
name: member.name,
|
||||
coverage: Math.min(coverage, 200) // Cap at 200%
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const coverageText = computed(() => {
|
||||
if (memberCoverage.value.length === 0) return ''
|
||||
|
||||
const coverages = memberCoverage.value.map(m => m.coverage)
|
||||
const min = Math.min(...coverages)
|
||||
const max = Math.max(...coverages)
|
||||
|
||||
if (min >= 80) {
|
||||
return "Most members' needs are nearly covered."
|
||||
} else if (min < 50) {
|
||||
return "Some members' needs are far from covered — consider adjusting pay relationships."
|
||||
} else {
|
||||
return `On this plan, coverage ranges from ${Math.round(min)}% to ${Math.round(max)}% of members' needs.`
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
function project(includeRevenue: boolean, forceNoIncome = false) {
|
||||
const balances: number[] = []
|
||||
let bal = toNum(props.startingCash)
|
||||
|
||||
// Pad arrays to 12 elements if shorter
|
||||
const revenuePadded = Array.isArray(props.revenuePlanned) ?
|
||||
[...props.revenuePlanned, ...Array(12 - props.revenuePlanned.length).fill(0)].slice(0, 12) :
|
||||
Array(12).fill(0)
|
||||
const expensesPadded = Array.isArray(props.expensePlanned) ?
|
||||
[...props.expensePlanned, ...Array(12 - props.expensePlanned.length).fill(0)].slice(0, 12) :
|
||||
Array(12).fill(0)
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const inflow = (includeRevenue && !forceNoIncome) ? toNum(revenuePadded[i]) : 0
|
||||
const outflow = toNum(expensesPadded[i])
|
||||
bal = bal + inflow - outflow
|
||||
balances.push(bal)
|
||||
}
|
||||
return balances
|
||||
}
|
||||
|
||||
const withRev = computed(() => project(true, imagineNoIncome.value))
|
||||
const noRev = computed(() => project(false, true))
|
||||
|
||||
function runwayFrom(balances: number[]) {
|
||||
// find first month index where balance < 0
|
||||
const i = balances.findIndex(b => b < 0)
|
||||
if (i === -1) return 12 // survived 12+ months
|
||||
// linear interpolation within month i
|
||||
const prev = i === 0 ? toNum(props.startingCash) : toNum(balances[i-1])
|
||||
const delta = toNum(balances[i]) - prev
|
||||
const frac = delta === 0 ? 0 : (0 - prev) / delta // 0..1
|
||||
return Math.max(0, (i-1) + frac + 1) // months from now
|
||||
}
|
||||
|
||||
// Check if we have meaningful data
|
||||
const hasData = computed(() => {
|
||||
return props.startingCash > 0 ||
|
||||
(Array.isArray(props.revenuePlanned) && props.revenuePlanned.some(v => v > 0)) ||
|
||||
(Array.isArray(props.expensePlanned) && props.expensePlanned.some(v => v > 0))
|
||||
})
|
||||
|
||||
const runwayMonths = computed(() => {
|
||||
if (!hasData.value) return 0
|
||||
if (monthlyCosts.value <= 0) return 12
|
||||
|
||||
if (imagineNoIncome.value) {
|
||||
return runwayFrom(noRev.value)
|
||||
} else {
|
||||
return includePlannedRevenue.value ? runwayFrom(withRev.value) : runwayFrom(noRev.value)
|
||||
}
|
||||
})
|
||||
|
||||
const mainHeadline = computed(() => {
|
||||
const months = runwayMonths.value >= 12 ? '12+' : runwayMonths.value.toFixed(1)
|
||||
return `You could keep going for about ${months} months.`
|
||||
})
|
||||
|
||||
const subHeadline = computed(() => {
|
||||
return `That's with monthly costs of ${fmtCurrency(monthlyCosts.value)} and the income ideas you entered.`
|
||||
})
|
||||
|
||||
const guidanceText = computed(() => {
|
||||
const months = runwayMonths.value
|
||||
if (months < 3) {
|
||||
return "This sketch shows less than 3 months covered — that's risky."
|
||||
} else if (months <= 6) {
|
||||
return "This sketch shows about 3–6 months — that's a common minimum target."
|
||||
} else {
|
||||
return "This sketch shows more than 6 months — a safer position."
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const bufferFlagText = computed(() => {
|
||||
return guidanceText.value
|
||||
})
|
||||
|
||||
|
||||
// Out-of-money month computations
|
||||
const oocWith = computed(() => outOfCashMonth(withRev.value))
|
||||
const oocNo = computed(() => outOfCashMonth(noRev.value))
|
||||
|
||||
// Path to Safe Buffer calculation
|
||||
|
||||
// Break-even Month calculation
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const projectionData = computed(() => {
|
||||
const monthIndices = [...Array(13).keys()] // 0..12 for chart display
|
||||
const withIncome = []
|
||||
const withoutIncome = []
|
||||
|
||||
// Pad arrays to 12 elements if shorter
|
||||
const revenuePadded = Array.isArray(props.revenuePlanned) ?
|
||||
[...props.revenuePlanned, ...Array(12 - props.revenuePlanned.length).fill(0)].slice(0, 12) :
|
||||
Array(12).fill(0)
|
||||
const expensesPadded = Array.isArray(props.expensePlanned) ?
|
||||
[...props.expensePlanned, ...Array(12 - props.expensePlanned.length).fill(0)].slice(0, 12) :
|
||||
Array(12).fill(0)
|
||||
|
||||
// Start with initial balance
|
||||
withIncome.push(toNum(props.startingCash))
|
||||
withoutIncome.push(toNum(props.startingCash))
|
||||
|
||||
// Project forward month by month
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const lastWithIncome = withIncome[withIncome.length - 1]
|
||||
const lastWithoutIncome = withoutIncome[withoutIncome.length - 1]
|
||||
|
||||
// Safe access to array values using toNum helper
|
||||
const revenueAmount = toNum(revenuePadded[i])
|
||||
const expenseAmount = toNum(expensesPadded[i])
|
||||
|
||||
// Line A: with income ideas
|
||||
const withIncomeBalance = lastWithIncome + revenueAmount - expenseAmount
|
||||
withIncome.push(withIncomeBalance)
|
||||
|
||||
// Line B: no income
|
||||
const withoutIncomeBalance = lastWithoutIncome - expenseAmount
|
||||
withoutIncome.push(withoutIncomeBalance)
|
||||
}
|
||||
|
||||
return { months: monthIndices, withIncome, withoutIncome }
|
||||
})
|
||||
|
||||
const drawChart = () => {
|
||||
if (!chartCanvas.value) return
|
||||
|
||||
const canvas = chartCanvas.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
if (!hasData.value) {
|
||||
// Draw empty state in chart
|
||||
ctx.fillStyle = '#6b7280'
|
||||
ctx.font = '14px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('No data available', canvas.width / 2, canvas.height / 2)
|
||||
return
|
||||
}
|
||||
|
||||
const { months, withIncome, withoutIncome } = projectionData.value
|
||||
|
||||
const padding = 40
|
||||
const chartWidth = canvas.width - padding * 2
|
||||
const chartHeight = canvas.height - padding * 2
|
||||
|
||||
// Calculate scale - ensure all values are finite numbers
|
||||
const allValues = [...withIncome, ...withoutIncome].map(v => toNum(v))
|
||||
const maxValue = Math.max(...allValues, toNum(props.startingCash))
|
||||
const minValue = Math.min(...allValues, 0)
|
||||
const valueRange = Math.max(maxValue - minValue, 1000) // ensure minimum range
|
||||
|
||||
const scaleX = chartWidth / 12
|
||||
const scaleY = chartHeight / valueRange
|
||||
|
||||
// Helper function to get canvas coordinates
|
||||
const getX = (month: number) => padding + (month * scaleX)
|
||||
const getY = (value: number) => padding + chartHeight - ((value - minValue) * scaleY)
|
||||
|
||||
// Fill background red where balance < 0
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.1)'
|
||||
const zeroY = getY(0)
|
||||
ctx.fillRect(padding, zeroY, chartWidth, canvas.height - zeroY - padding)
|
||||
|
||||
// Draw grid lines
|
||||
ctx.strokeStyle = '#e5e7eb'
|
||||
ctx.lineWidth = 1
|
||||
|
||||
// Horizontal grid lines
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = padding + (chartHeight / 4) * i
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padding, y)
|
||||
ctx.lineTo(padding + chartWidth, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Vertical grid lines
|
||||
for (let i = 0; i <= 12; i += 3) {
|
||||
const x = getX(i)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, padding)
|
||||
ctx.lineTo(x, padding + chartHeight)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Draw zero line
|
||||
ctx.strokeStyle = '#6b7280'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padding, zeroY)
|
||||
ctx.lineTo(padding + chartWidth, zeroY)
|
||||
ctx.stroke()
|
||||
|
||||
// Draw vertical reference lines at out-of-cash points
|
||||
ctx.strokeStyle = '#ef4444'
|
||||
ctx.lineWidth = 1
|
||||
ctx.setLineDash([5, 5])
|
||||
|
||||
if (oocWith.value !== -1) {
|
||||
const x = getX(oocWith.value)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, padding)
|
||||
ctx.lineTo(x, padding + chartHeight)
|
||||
ctx.stroke()
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = '#ef4444'
|
||||
ctx.font = '10px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('OOC', x, padding - 5)
|
||||
}
|
||||
|
||||
if (oocNo.value !== -1 && oocNo.value !== oocWith.value) {
|
||||
const x = getX(oocNo.value)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, padding)
|
||||
ctx.lineTo(x, padding + chartHeight)
|
||||
ctx.stroke()
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = '#ef4444'
|
||||
ctx.font = '10px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('OOC', x, padding - 5)
|
||||
}
|
||||
|
||||
ctx.setLineDash([])
|
||||
|
||||
// Draw Line A (with income) - always show, bold if selected
|
||||
ctx.strokeStyle = '#22c55e'
|
||||
ctx.lineWidth = (includePlannedRevenue.value && !imagineNoIncome.value) ? 3 : 2
|
||||
ctx.globalAlpha = (includePlannedRevenue.value && !imagineNoIncome.value) ? 1 : 0.6
|
||||
ctx.beginPath()
|
||||
withIncome.forEach((value, index) => {
|
||||
const x = getX(index)
|
||||
const y = getY(toNum(value))
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
})
|
||||
ctx.stroke()
|
||||
|
||||
// Add point annotation where active scenario crosses y=0
|
||||
if (includePlannedRevenue.value && !imagineNoIncome.value) {
|
||||
const crossingIdx = withIncome.findIndex((value, idx) => {
|
||||
if (idx === 0) return false
|
||||
const prev = toNum(withIncome[idx - 1])
|
||||
const curr = toNum(value)
|
||||
return prev >= 0 && curr < 0
|
||||
})
|
||||
|
||||
if (crossingIdx !== -1) {
|
||||
const x = getX(crossingIdx)
|
||||
const y = getY(0)
|
||||
ctx.fillStyle = '#22c55e'
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 4, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
|
||||
// Add label
|
||||
ctx.fillStyle = '#22c55e'
|
||||
ctx.font = '10px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('Out of money', x, y - 10)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw Line B (no income) - always show, bold if selected
|
||||
ctx.strokeStyle = '#ef4444'
|
||||
ctx.lineWidth = (!includePlannedRevenue.value || imagineNoIncome.value) ? 3 : 2
|
||||
ctx.globalAlpha = (!includePlannedRevenue.value || imagineNoIncome.value) ? 1 : 0.6
|
||||
ctx.beginPath()
|
||||
withoutIncome.forEach((value, index) => {
|
||||
const x = getX(index)
|
||||
const y = getY(toNum(value))
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
})
|
||||
ctx.stroke()
|
||||
|
||||
// Add point annotation where active scenario crosses y=0
|
||||
if (!includePlannedRevenue.value || imagineNoIncome.value) {
|
||||
const crossingIdx = withoutIncome.findIndex((value, idx) => {
|
||||
if (idx === 0) return false
|
||||
const prev = toNum(withoutIncome[idx - 1])
|
||||
const curr = toNum(value)
|
||||
return prev >= 0 && curr < 0
|
||||
})
|
||||
|
||||
if (crossingIdx !== -1) {
|
||||
const x = getX(crossingIdx)
|
||||
const y = getY(0)
|
||||
ctx.fillStyle = '#ef4444'
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 4, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
|
||||
// Add label
|
||||
ctx.fillStyle = '#ef4444'
|
||||
ctx.font = '10px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('Out of money', x, y - 10)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
|
||||
// Draw axis labels
|
||||
ctx.fillStyle = '#6b7280'
|
||||
ctx.font = '12px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
|
||||
// X-axis labels (months)
|
||||
for (let i = 0; i <= 12; i += 3) {
|
||||
const x = getX(i)
|
||||
ctx.fillText(i.toString(), x, canvas.height - 10)
|
||||
}
|
||||
|
||||
// Y-axis labels (balance) - guarded formatting
|
||||
ctx.textAlign = 'right'
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const value = minValue + (valueRange / 4) * (4 - i)
|
||||
const y = padding + (chartHeight / 4) * i + 4
|
||||
const formattedValue = Number.isFinite(value) ? fmtShort(value) : '0'
|
||||
ctx.fillText(formattedValue, padding - 10, y)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes that should trigger chart redraw
|
||||
watch([includePlannedRevenue, imagineNoIncome, projectionData], () => {
|
||||
nextTick(() => drawChart())
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => drawChart())
|
||||
})
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue