feat: add initial application structure with configuration, UI components, and state management
This commit is contained in:
parent
fadf94002c
commit
0af6b17792
56 changed files with 6137 additions and 129 deletions
59
composables/useConcentration.ts
Normal file
59
composables/useConcentration.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Returns Top source % and HHI-based traffic light (HHI hidden from UI)
|
||||
* Uses Herfindahl-Hirschman Index internally but only exposes traffic light color
|
||||
*/
|
||||
export const useConcentration = () => {
|
||||
const calculateTopSourcePct = (revenueShares: number[]): number => {
|
||||
if (revenueShares.length === 0) return 0
|
||||
return Math.max(...revenueShares)
|
||||
}
|
||||
|
||||
const calculateHHI = (revenueShares: number[]): number => {
|
||||
// HHI = sum of squared market shares (as percentages)
|
||||
return revenueShares.reduce((sum, share) => sum + (share * share), 0)
|
||||
}
|
||||
|
||||
const getConcentrationStatus = (topSourcePct: number, hhi: number): 'green' | 'yellow' | 'red' => {
|
||||
// Primary threshold based on top source %
|
||||
if (topSourcePct > 50) return 'red'
|
||||
if (topSourcePct > 35) return 'yellow'
|
||||
|
||||
// Secondary check using HHI for more nuanced analysis
|
||||
if (hhi > 2500) return 'red' // Highly concentrated
|
||||
if (hhi > 1500) return 'yellow' // Moderately concentrated
|
||||
|
||||
return 'green'
|
||||
}
|
||||
|
||||
const getConcentrationMessage = (status: 'green' | 'yellow' | 'red'): string => {
|
||||
switch (status) {
|
||||
case 'red':
|
||||
return 'Most of your money comes from one place. Add another stream to reduce risk.'
|
||||
case 'yellow':
|
||||
return 'Revenue somewhat concentrated. Consider diversifying further.'
|
||||
case 'green':
|
||||
return 'Good revenue diversification.'
|
||||
}
|
||||
}
|
||||
|
||||
const analyzeConcentration = (revenueStreams: Array<{ targetPct: number }>) => {
|
||||
const shares = revenueStreams.map(stream => stream.targetPct || 0)
|
||||
const topSourcePct = calculateTopSourcePct(shares)
|
||||
const hhi = calculateHHI(shares)
|
||||
const status = getConcentrationStatus(topSourcePct, hhi)
|
||||
|
||||
return {
|
||||
topSourcePct,
|
||||
status,
|
||||
message: getConcentrationMessage(status)
|
||||
// Note: HHI is deliberately not exposed in return
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
calculateTopSourcePct,
|
||||
getConcentrationStatus,
|
||||
getConcentrationMessage,
|
||||
analyzeConcentration
|
||||
}
|
||||
}
|
||||
25
composables/useCoverage.ts
Normal file
25
composables/useCoverage.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* Computes coverage: funded paid hours ÷ target hours
|
||||
*/
|
||||
export const useCoverage = () => {
|
||||
const calculateCoverage = (fundedPaidHours: number, targetHours: number): number => {
|
||||
if (targetHours <= 0) return 0
|
||||
return (fundedPaidHours / targetHours) * 100
|
||||
}
|
||||
|
||||
const getCoverageStatus = (coveragePct: number): 'green' | 'yellow' | 'red' => {
|
||||
if (coveragePct >= 80) return 'green'
|
||||
if (coveragePct >= 60) return 'yellow'
|
||||
return 'red'
|
||||
}
|
||||
|
||||
const formatCoverage = (coveragePct: number): string => {
|
||||
return `${Math.round(coveragePct)}%`
|
||||
}
|
||||
|
||||
return {
|
||||
calculateCoverage,
|
||||
getCoverageStatus,
|
||||
formatCoverage
|
||||
}
|
||||
}
|
||||
89
composables/useDeferredMetrics.ts
Normal file
89
composables/useDeferredMetrics.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* Calculates deferred balance and ratio vs monthly payroll
|
||||
* Formula: total deferred wage liability ÷ one month of payroll
|
||||
*/
|
||||
export const useDeferredMetrics = () => {
|
||||
const calculateDeferredBalance = (
|
||||
members: Array<{ deferredHours: number }>,
|
||||
hourlyWage: number
|
||||
): number => {
|
||||
const totalDeferredHours = members.reduce((sum, member) => sum + (member.deferredHours || 0), 0)
|
||||
return totalDeferredHours * hourlyWage
|
||||
}
|
||||
|
||||
const calculateMonthlyPayroll = (
|
||||
members: Array<{ targetHours: number }>,
|
||||
hourlyWage: number,
|
||||
oncostPct: number
|
||||
): number => {
|
||||
const totalTargetHours = members.reduce((sum, member) => sum + (member.targetHours || 0), 0)
|
||||
const grossPayroll = totalTargetHours * hourlyWage
|
||||
return grossPayroll * (1 + oncostPct / 100)
|
||||
}
|
||||
|
||||
const calculateDeferredRatio = (
|
||||
deferredBalance: number,
|
||||
monthlyPayroll: number
|
||||
): number => {
|
||||
if (monthlyPayroll <= 0) return 0
|
||||
return deferredBalance / monthlyPayroll
|
||||
}
|
||||
|
||||
const getDeferredRatioStatus = (ratio: number): 'green' | 'yellow' | 'red' => {
|
||||
if (ratio > 1.5) return 'red' // Flag red if >1.5× monthly payroll
|
||||
if (ratio > 1.0) return 'yellow'
|
||||
return 'green'
|
||||
}
|
||||
|
||||
const getDeferredRatioMessage = (status: 'green' | 'yellow' | 'red'): string => {
|
||||
switch (status) {
|
||||
case 'red':
|
||||
return 'Deferred balance is high. Consider repaying or reducing scope.'
|
||||
case 'yellow':
|
||||
return 'Deferred balance is building up. Monitor closely.'
|
||||
case 'green':
|
||||
return 'Deferred balance is manageable.'
|
||||
}
|
||||
}
|
||||
|
||||
const checkDeferredCap = (
|
||||
memberHours: number,
|
||||
capHoursPerQtr: number,
|
||||
quarterProgress: number
|
||||
): { withinCap: boolean; remainingHours: number } => {
|
||||
const quarterlyLimit = capHoursPerQtr * quarterProgress
|
||||
const withinCap = memberHours <= quarterlyLimit
|
||||
const remainingHours = Math.max(0, quarterlyLimit - memberHours)
|
||||
|
||||
return { withinCap, remainingHours }
|
||||
}
|
||||
|
||||
const analyzeDeferredMetrics = (
|
||||
members: Array<{ deferredHours?: number; targetHours?: number }>,
|
||||
hourlyWage: number,
|
||||
oncostPct: number
|
||||
) => {
|
||||
const deferredBalance = calculateDeferredBalance(members, hourlyWage)
|
||||
const monthlyPayroll = calculateMonthlyPayroll(members, hourlyWage, oncostPct)
|
||||
const ratio = calculateDeferredRatio(deferredBalance, monthlyPayroll)
|
||||
const status = getDeferredRatioStatus(ratio)
|
||||
|
||||
return {
|
||||
deferredBalance,
|
||||
monthlyPayroll,
|
||||
ratio: Number(ratio.toFixed(2)),
|
||||
status,
|
||||
message: getDeferredRatioMessage(status)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
calculateDeferredBalance,
|
||||
calculateMonthlyPayroll,
|
||||
calculateDeferredRatio,
|
||||
getDeferredRatioStatus,
|
||||
getDeferredRatioMessage,
|
||||
checkDeferredCap,
|
||||
analyzeDeferredMetrics
|
||||
}
|
||||
}
|
||||
77
composables/useFixtureIO.ts
Normal file
77
composables/useFixtureIO.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useMembersStore } from '~/stores/members'
|
||||
import { usePoliciesStore } from '~/stores/policies'
|
||||
import { useStreamsStore } from '~/stores/streams'
|
||||
import { useBudgetStore } from '~/stores/budget'
|
||||
import { useScenariosStore } from '~/stores/scenarios'
|
||||
import { useCashStore } from '~/stores/cash'
|
||||
import { useSessionStore } from '~/stores/session'
|
||||
|
||||
export type AppSnapshot = {
|
||||
members: any[]
|
||||
policies: Record<string, any>
|
||||
streams: any[]
|
||||
budget: Record<string, any>
|
||||
scenarios: Record<string, any>
|
||||
cash: Record<string, any>
|
||||
session: Record<string, any>
|
||||
}
|
||||
|
||||
export function useFixtureIO() {
|
||||
const exportAll = (): AppSnapshot => {
|
||||
const members = useMembersStore()
|
||||
const policies = usePoliciesStore()
|
||||
const streams = useStreamsStore()
|
||||
const budget = useBudgetStore()
|
||||
const scenarios = useScenariosStore()
|
||||
const cash = useCashStore()
|
||||
const session = useSessionStore()
|
||||
|
||||
return {
|
||||
members: members.members,
|
||||
policies: {
|
||||
equalHourlyWage: policies.equalHourlyWage,
|
||||
payrollOncostPct: policies.payrollOncostPct,
|
||||
savingsTargetMonths: policies.savingsTargetMonths,
|
||||
minCashCushionAmount: policies.minCashCushionAmount,
|
||||
deferredCapHoursPerQtr: policies.deferredCapHoursPerQtr,
|
||||
deferredSunsetMonths: policies.deferredSunsetMonths,
|
||||
surplusOrder: policies.surplusOrder,
|
||||
paymentPriority: policies.paymentPriority
|
||||
},
|
||||
streams: streams.streams,
|
||||
budget: {
|
||||
budgetLines: budget.budgetLines,
|
||||
overheadCosts: budget.overheadCosts,
|
||||
productionCosts: budget.productionCosts,
|
||||
currentPeriod: budget.currentPeriod
|
||||
},
|
||||
scenarios: {
|
||||
sliders: scenarios.sliders,
|
||||
activeScenario: scenarios.activeScenario
|
||||
},
|
||||
cash: {
|
||||
cashEvents: cash.cashEvents,
|
||||
paymentQueue: cash.paymentQueue,
|
||||
currentCash: cash.currentCash,
|
||||
currentSavings: cash.currentSavings
|
||||
},
|
||||
session: {
|
||||
checklist: session.checklist,
|
||||
draftAllocations: session.draftAllocations,
|
||||
rationale: session.rationale,
|
||||
currentSession: session.currentSession,
|
||||
savedRecords: session.savedRecords
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const importAll = (snapshot: AppSnapshot) => {
|
||||
// TODO: Implement import functionality for all stores
|
||||
// This will patch each store with the snapshot data
|
||||
console.log('Import functionality to be implemented', snapshot)
|
||||
}
|
||||
|
||||
return { exportAll, importAll }
|
||||
}
|
||||
|
||||
|
||||
246
composables/useFixtures.ts
Normal file
246
composables/useFixtures.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
/**
|
||||
* Composable for loading and managing fixture data
|
||||
* Provides centralized access to demo data for all screens
|
||||
*/
|
||||
export const useFixtures = () => {
|
||||
// Load fixture data (in real app, this would come from API or stores)
|
||||
const loadMembers = async () => {
|
||||
// In production, this would fetch from content/fixtures/members.json
|
||||
// For now, return inline data that matches the fixture structure
|
||||
return {
|
||||
members: [
|
||||
{
|
||||
id: 'member-1',
|
||||
displayName: 'Alex Chen',
|
||||
roleFocus: 'Technical Lead',
|
||||
payRelationship: 'Hybrid',
|
||||
capacity: { minHours: 20, targetHours: 120, maxHours: 160 },
|
||||
riskBand: 'Medium',
|
||||
externalCoveragePct: 60,
|
||||
privacyNeeds: 'aggregate_ok',
|
||||
deferredHours: 85,
|
||||
quarterlyDeferredCap: 240
|
||||
},
|
||||
{
|
||||
id: 'member-2',
|
||||
displayName: 'Jordan Silva',
|
||||
roleFocus: 'Design & UX',
|
||||
payRelationship: 'FullyPaid',
|
||||
capacity: { minHours: 30, targetHours: 140, maxHours: 180 },
|
||||
riskBand: 'Low',
|
||||
externalCoveragePct: 20,
|
||||
privacyNeeds: 'aggregate_ok',
|
||||
deferredHours: 0,
|
||||
quarterlyDeferredCap: 240
|
||||
},
|
||||
{
|
||||
id: 'member-3',
|
||||
displayName: 'Sam Rodriguez',
|
||||
roleFocus: 'Operations & Growth',
|
||||
payRelationship: 'Supplemental',
|
||||
capacity: { minHours: 10, targetHours: 60, maxHours: 100 },
|
||||
riskBand: 'High',
|
||||
externalCoveragePct: 85,
|
||||
privacyNeeds: 'steward_only',
|
||||
deferredHours: 32,
|
||||
quarterlyDeferredCap: 120
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const loadStreams = async () => {
|
||||
return {
|
||||
revenueStreams: [
|
||||
{
|
||||
id: 'stream-1',
|
||||
name: 'Client Services',
|
||||
category: 'Services',
|
||||
subcategory: 'Development',
|
||||
targetPct: 65,
|
||||
targetMonthlyAmount: 7800,
|
||||
certainty: 'Committed',
|
||||
payoutDelayDays: 30,
|
||||
terms: 'Net 30',
|
||||
revenueSharePct: 0,
|
||||
platformFeePct: 0,
|
||||
restrictions: 'General',
|
||||
effortHoursPerMonth: 180
|
||||
},
|
||||
{
|
||||
id: 'stream-2',
|
||||
name: 'Platform Sales',
|
||||
category: 'Product',
|
||||
subcategory: 'Digital Tools',
|
||||
targetPct: 20,
|
||||
targetMonthlyAmount: 2400,
|
||||
certainty: 'Probable',
|
||||
payoutDelayDays: 14,
|
||||
terms: 'Platform payout',
|
||||
revenueSharePct: 0,
|
||||
platformFeePct: 5,
|
||||
restrictions: 'General',
|
||||
effortHoursPerMonth: 40
|
||||
},
|
||||
{
|
||||
id: 'stream-3',
|
||||
name: 'Innovation Grant',
|
||||
category: 'Grant',
|
||||
subcategory: 'Government',
|
||||
targetPct: 10,
|
||||
targetMonthlyAmount: 1200,
|
||||
certainty: 'Committed',
|
||||
payoutDelayDays: 45,
|
||||
terms: 'Quarterly disbursement',
|
||||
revenueSharePct: 0,
|
||||
platformFeePct: 0,
|
||||
restrictions: 'Restricted',
|
||||
effortHoursPerMonth: 8
|
||||
},
|
||||
{
|
||||
id: 'stream-4',
|
||||
name: 'Community Donations',
|
||||
category: 'Donation',
|
||||
subcategory: 'Individual',
|
||||
targetPct: 3,
|
||||
targetMonthlyAmount: 360,
|
||||
certainty: 'Aspirational',
|
||||
payoutDelayDays: 3,
|
||||
terms: 'Immediate',
|
||||
revenueSharePct: 0,
|
||||
platformFeePct: 2.9,
|
||||
restrictions: 'General',
|
||||
effortHoursPerMonth: 5
|
||||
},
|
||||
{
|
||||
id: 'stream-5',
|
||||
name: 'Consulting & Training',
|
||||
category: 'Other',
|
||||
subcategory: 'Professional Services',
|
||||
targetPct: 2,
|
||||
targetMonthlyAmount: 240,
|
||||
certainty: 'Probable',
|
||||
payoutDelayDays: 21,
|
||||
terms: 'Net 21',
|
||||
revenueSharePct: 0,
|
||||
platformFeePct: 0,
|
||||
restrictions: 'General',
|
||||
effortHoursPerMonth: 12
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const loadFinances = async () => {
|
||||
return {
|
||||
currentBalances: {
|
||||
cash: 5000,
|
||||
savings: 8000,
|
||||
totalLiquid: 13000
|
||||
},
|
||||
policies: {
|
||||
equalHourlyWage: 20,
|
||||
payrollOncostPct: 25,
|
||||
savingsTargetMonths: 3,
|
||||
minCashCushionAmount: 3000,
|
||||
deferredCapHoursPerQtr: 240,
|
||||
deferredSunsetMonths: 12
|
||||
},
|
||||
deferredLiabilities: {
|
||||
totalDeferred: 2340,
|
||||
byMember: {
|
||||
'member-1': 1700,
|
||||
'member-2': 0,
|
||||
'member-3': 640
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadCosts = async () => {
|
||||
return {
|
||||
overheadCosts: [
|
||||
{
|
||||
id: 'overhead-1',
|
||||
name: 'Coworking Space',
|
||||
amount: 800,
|
||||
category: 'Workspace',
|
||||
recurring: true
|
||||
},
|
||||
{
|
||||
id: 'overhead-2',
|
||||
name: 'Tools & Software',
|
||||
amount: 420,
|
||||
category: 'Technology',
|
||||
recurring: true
|
||||
},
|
||||
{
|
||||
id: 'overhead-3',
|
||||
name: 'Business Insurance',
|
||||
amount: 180,
|
||||
category: 'Legal & Compliance',
|
||||
recurring: true
|
||||
}
|
||||
],
|
||||
productionCosts: [
|
||||
{
|
||||
id: 'production-1',
|
||||
name: 'Development Kits',
|
||||
amount: 500,
|
||||
category: 'Hardware',
|
||||
period: '2024-01'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate derived metrics from fixture data
|
||||
const calculateMetrics = async () => {
|
||||
const [members, streams, finances, costs] = await Promise.all([
|
||||
loadMembers(),
|
||||
loadStreams(),
|
||||
loadFinances(),
|
||||
loadCosts()
|
||||
])
|
||||
|
||||
const totalTargetHours = members.members.reduce((sum, member) =>
|
||||
sum + member.capacity.targetHours, 0
|
||||
)
|
||||
|
||||
const totalTargetRevenue = streams.revenueStreams.reduce((sum, stream) =>
|
||||
sum + stream.targetMonthlyAmount, 0
|
||||
)
|
||||
|
||||
const totalOverheadCosts = costs.overheadCosts.reduce((sum, cost) =>
|
||||
sum + cost.amount, 0
|
||||
)
|
||||
|
||||
const monthlyPayroll = totalTargetHours * finances.policies.equalHourlyWage *
|
||||
(1 + finances.policies.payrollOncostPct / 100)
|
||||
|
||||
const monthlyBurn = monthlyPayroll + totalOverheadCosts +
|
||||
costs.productionCosts.reduce((sum, cost) => sum + cost.amount, 0)
|
||||
|
||||
const runway = finances.currentBalances.totalLiquid / monthlyBurn
|
||||
|
||||
return {
|
||||
totalTargetHours,
|
||||
totalTargetRevenue,
|
||||
monthlyPayroll,
|
||||
monthlyBurn,
|
||||
runway,
|
||||
members: members.members,
|
||||
streams: streams.revenueStreams,
|
||||
finances: finances,
|
||||
costs: costs
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadMembers,
|
||||
loadStreams,
|
||||
loadFinances,
|
||||
loadCosts,
|
||||
calculateMetrics
|
||||
}
|
||||
}
|
||||
58
composables/usePayoutExposure.ts
Normal file
58
composables/usePayoutExposure.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* Calculates weighted average payout delay across revenue streams
|
||||
*/
|
||||
export const usePayoutExposure = () => {
|
||||
const calculateWeightedAverageDelay = (
|
||||
streams: Array<{ targetMonthlyAmount: number; payoutDelayDays: number }>
|
||||
): number => {
|
||||
const totalRevenue = streams.reduce((sum, stream) => sum + (stream.targetMonthlyAmount || 0), 0)
|
||||
|
||||
if (totalRevenue === 0) return 0
|
||||
|
||||
const weightedSum = streams.reduce((sum, stream) => {
|
||||
const weight = (stream.targetMonthlyAmount || 0) / totalRevenue
|
||||
return sum + (weight * (stream.payoutDelayDays || 0))
|
||||
}, 0)
|
||||
|
||||
return weightedSum
|
||||
}
|
||||
|
||||
const getPayoutExposureStatus = (avgDelayDays: number, minCashCushion: number): 'green' | 'yellow' | 'red' => {
|
||||
// Flag if weighted average >30 days and cushion <1 month
|
||||
if (avgDelayDays > 30 && minCashCushion < 8000) return 'red' // Assuming ~8k = 1 month expenses
|
||||
if (avgDelayDays > 21) return 'yellow'
|
||||
return 'green'
|
||||
}
|
||||
|
||||
const getPayoutExposureMessage = (status: 'green' | 'yellow' | 'red'): string => {
|
||||
switch (status) {
|
||||
case 'red':
|
||||
return 'Money is earned now but arrives later. Delays can create mid-month dips.'
|
||||
case 'yellow':
|
||||
return 'Some payout delays present. Monitor cash timing.'
|
||||
case 'green':
|
||||
return 'Payout timing looks manageable.'
|
||||
}
|
||||
}
|
||||
|
||||
const analyzePayoutExposure = (
|
||||
streams: Array<{ targetMonthlyAmount: number; payoutDelayDays: number }>,
|
||||
minCashCushion: number
|
||||
) => {
|
||||
const avgDelayDays = calculateWeightedAverageDelay(streams)
|
||||
const status = getPayoutExposureStatus(avgDelayDays, minCashCushion)
|
||||
|
||||
return {
|
||||
avgDelayDays: Math.round(avgDelayDays),
|
||||
status,
|
||||
message: getPayoutExposureMessage(status)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
calculateWeightedAverageDelay,
|
||||
getPayoutExposureStatus,
|
||||
getPayoutExposureMessage,
|
||||
analyzePayoutExposure
|
||||
}
|
||||
}
|
||||
74
composables/useReserveProgress.ts
Normal file
74
composables/useReserveProgress.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* Calculates percentage of savings target met
|
||||
* Formula: savings ÷ (savings_target_months × monthly burn)
|
||||
*/
|
||||
export const useReserveProgress = () => {
|
||||
const calculateReserveProgress = (
|
||||
currentSavings: number,
|
||||
savingsTargetMonths: number,
|
||||
monthlyBurn: number
|
||||
): number => {
|
||||
const targetAmount = savingsTargetMonths * monthlyBurn
|
||||
if (targetAmount <= 0) return 100
|
||||
return Math.min((currentSavings / targetAmount) * 100, 100)
|
||||
}
|
||||
|
||||
const getReserveProgressStatus = (progressPct: number): 'green' | 'yellow' | 'red' => {
|
||||
if (progressPct >= 80) return 'green'
|
||||
if (progressPct >= 50) return 'yellow'
|
||||
return 'red'
|
||||
}
|
||||
|
||||
const getReserveProgressMessage = (status: 'green' | 'yellow' | 'red'): string => {
|
||||
switch (status) {
|
||||
case 'red':
|
||||
return 'Build savings to your target before increasing paid hours.'
|
||||
case 'yellow':
|
||||
return 'Savings progress is moderate. Continue building reserves.'
|
||||
case 'green':
|
||||
return 'Savings target nearly reached or exceeded.'
|
||||
}
|
||||
}
|
||||
|
||||
const calculateTargetAmount = (savingsTargetMonths: number, monthlyBurn: number): number => {
|
||||
return savingsTargetMonths * monthlyBurn
|
||||
}
|
||||
|
||||
const calculateShortfall = (
|
||||
currentSavings: number,
|
||||
savingsTargetMonths: number,
|
||||
monthlyBurn: number
|
||||
): number => {
|
||||
const targetAmount = calculateTargetAmount(savingsTargetMonths, monthlyBurn)
|
||||
return Math.max(0, targetAmount - currentSavings)
|
||||
}
|
||||
|
||||
const analyzeReserveProgress = (
|
||||
currentSavings: number,
|
||||
savingsTargetMonths: number,
|
||||
monthlyBurn: number
|
||||
) => {
|
||||
const progressPct = calculateReserveProgress(currentSavings, savingsTargetMonths, monthlyBurn)
|
||||
const status = getReserveProgressStatus(progressPct)
|
||||
const targetAmount = calculateTargetAmount(savingsTargetMonths, monthlyBurn)
|
||||
const shortfall = calculateShortfall(currentSavings, savingsTargetMonths, monthlyBurn)
|
||||
|
||||
return {
|
||||
progressPct: Math.round(progressPct),
|
||||
status,
|
||||
message: getReserveProgressMessage(status),
|
||||
currentSavings,
|
||||
targetAmount,
|
||||
shortfall
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
calculateReserveProgress,
|
||||
getReserveProgressStatus,
|
||||
getReserveProgressMessage,
|
||||
calculateTargetAmount,
|
||||
calculateShortfall,
|
||||
analyzeReserveProgress
|
||||
}
|
||||
}
|
||||
27
composables/useRunway.ts
Normal file
27
composables/useRunway.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Computes months of runway from cash, reserves, and burn rate
|
||||
* Formula: (cash + savings) ÷ average monthly burn in scenario
|
||||
*/
|
||||
export const useRunway = () => {
|
||||
const calculateRunway = (cash: number, savings: number, monthlyBurn: number): number => {
|
||||
if (monthlyBurn <= 0) return Infinity
|
||||
return (cash + savings) / monthlyBurn
|
||||
}
|
||||
|
||||
const getRunwayStatus = (months: number): 'green' | 'yellow' | 'red' => {
|
||||
if (months >= 3) return 'green'
|
||||
if (months >= 2) return 'yellow'
|
||||
return 'red'
|
||||
}
|
||||
|
||||
const formatRunway = (months: number): string => {
|
||||
if (months === Infinity) return '∞ months'
|
||||
return `${months.toFixed(1)} months`
|
||||
}
|
||||
|
||||
return {
|
||||
calculateRunway,
|
||||
getRunwayStatus,
|
||||
formatRunway
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue