app/components/RunwayLite.vue
2025-09-04 10:42:03 +01:00

513 lines
No EOL
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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.

<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 36 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>