feat: add initial application structure with configuration, UI components, and state management

This commit is contained in:
Jennie Robinson Faber 2025-08-09 18:13:16 +01:00
parent fadf94002c
commit 0af6b17792
56 changed files with 6137 additions and 129 deletions

View 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
}
}

View 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
}
}

View 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
}
}

View 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
View 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
}
}

View 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
}
}

View 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
View 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
}
}