refactor: enhance routing and state management in CoopBuilder, add migration checks on startup, and update Tailwind configuration for improved component styling

This commit is contained in:
Jennie Robinson Faber 2025-08-23 18:24:31 +01:00
parent 848386e3dd
commit 4cea1f71fe
55 changed files with 4053 additions and 1486 deletions

View file

@ -0,0 +1,364 @@
import { allocatePayroll as allocatePayrollImpl, monthlyPayroll, type Member, type PayPolicy } from '~/types/members'
export function useCoopBuilder() {
// Use the centralized Pinia store
const store = useCoopBuilderStore()
// Initialize store (but don't auto-load demo data)
onMounted(() => {
// Give the persistence plugin time to hydrate
nextTick(() => {
// Just ensure store is initialized but don't load demo data
// store.initializeDefaults() is disabled to prevent auto demo data
})
})
// Core computed values with error handling
const members = computed(() => {
try {
return store.members || []
} catch (e) {
console.warn('Error accessing members:', e)
return []
}
})
const streams = computed(() => {
try {
return store.streams || []
} catch (e) {
console.warn('Error accessing streams:', e)
return []
}
})
const policy = computed(() => {
try {
return store.policy || { relationship: 'equal-pay', roleBands: {} }
} catch (e) {
console.warn('Error accessing policy:', e)
return { relationship: 'equal-pay', roleBands: {} }
}
})
const operatingMode = computed({
get: () => {
try {
return store.operatingMode || 'min'
} catch (e) {
console.warn('Error accessing operating mode:', e)
return 'min' as 'min' | 'target'
}
},
set: (value: 'min' | 'target') => {
try {
store.setOperatingMode(value)
} catch (e) {
console.warn('Error setting operating mode:', e)
}
}
})
const scenario = computed({
get: () => store.scenario,
set: (value) => store.setScenario(value)
})
const stress = computed({
get: () => store.stress,
set: (value) => store.updateStress(value)
})
const milestones = computed(() => store.milestones)
// Helper: Get scenario-transformed data
function getScenarioData() {
const baseMembers = [...members.value]
const baseStreams = [...streams.value]
switch (scenario.value) {
case 'quit-jobs':
return {
members: baseMembers.map(m => ({ ...m, externalMonthlyIncome: 0 })),
streams: baseStreams
}
case 'start-production':
return {
members: baseMembers,
streams: baseStreams.map(s => {
// Reduce service revenue by 30%
if (s.category?.toLowerCase().includes('service') || s.label.toLowerCase().includes('service')) {
return { ...s, monthly: (s.monthly || 0) * 0.7 }
}
return s
})
}
default:
return { members: baseMembers, streams: baseStreams }
}
}
// Helper: Apply stress test to scenario data
function getStressedData(baseData?: { members: Member[]; streams: any[] }) {
const data = baseData || getScenarioData()
const { revenueDelay, costShockPct, grantLost } = stress.value
if (revenueDelay === 0 && costShockPct === 0 && !grantLost) {
return data
}
let adjustedStreams = [...data.streams]
// Apply revenue delay (reduce revenue by delay percentage)
if (revenueDelay > 0) {
adjustedStreams = adjustedStreams.map(s => ({
...s,
monthly: (s.monthly || 0) * Math.max(0, 1 - (revenueDelay / 12))
}))
}
// Grant lost - remove largest grant
if (grantLost) {
const grantStreams = adjustedStreams.filter(s =>
s.category?.toLowerCase().includes('grant') ||
s.label.toLowerCase().includes('grant')
)
if (grantStreams.length > 0) {
const largestGrant = grantStreams.reduce((prev, current) =>
(prev.monthly || 0) > (current.monthly || 0) ? prev : current
)
adjustedStreams = adjustedStreams.map(s =>
s.id === largestGrant.id ? { ...s, monthly: 0 } : s
)
}
}
return {
members: data.members,
streams: adjustedStreams
}
}
// Payroll allocation
function allocatePayroll() {
const { members: scenarioMembers } = getScenarioData()
const payPolicy = policy.value
const totalRevenue = streams.value.reduce((sum, s) => sum + (s.monthly || 0), 0)
const overheadCosts = store.overheadCosts.reduce((sum, c) => sum + (c.amount || 0), 0)
const availableForPayroll = Math.max(0, totalRevenue - overheadCosts)
return allocatePayrollImpl(scenarioMembers, payPolicy as PayPolicy, availableForPayroll)
}
// Coverage calculation for a single member
function coverage(member: Member): { minPct: number; targetPct: number } {
const totalIncome = (member.monthlyPayPlanned || 0) + (member.externalMonthlyIncome || 0)
const minPct = member.minMonthlyNeeds > 0
? Math.min(200, (totalIncome / member.minMonthlyNeeds) * 100)
: 100
const targetPct = member.targetMonthlyPay > 0
? Math.min(200, (totalIncome / member.targetMonthlyPay) * 100)
: 100
return { minPct, targetPct }
}
// Team coverage statistics
function teamCoverageStats() {
try {
const allocatedMembers = allocatePayroll() || []
const coverages = allocatedMembers.map(m => coverage(m).minPct).filter(c => !isNaN(c))
if (coverages.length === 0) {
return { median: 0, under100: 0, over100Pct: 0, gini: 0 }
}
const sorted = [...coverages].sort((a, b) => a - b)
const median = sorted[Math.floor(sorted.length / 2)] || 0
const under100 = coverages.filter(c => c < 100).length
const over100Pct = coverages.length > 0
? Math.round(((coverages.length - under100) / coverages.length) * 100)
: 0
// Simple Gini coefficient approximation
const mean = coverages.reduce((sum, c) => sum + c, 0) / coverages.length
const gini = coverages.length > 1 && mean > 0
? coverages.reduce((sum, c) => sum + Math.abs(c - mean), 0) / (2 * coverages.length * mean)
: 0
return { median, under100, over100Pct, gini }
} catch (e) {
console.warn('Error calculating team coverage stats:', e)
return { median: 0, under100: 0, over100Pct: 0, gini: 0 }
}
}
// Revenue mix
function revenueMix() {
try {
const { streams: scenarioStreams } = getStressedData() || { streams: [] }
const validStreams = scenarioStreams.filter(s => s && typeof s === 'object')
const total = validStreams.reduce((sum, s) => sum + (s.monthly || 0), 0)
return validStreams
.filter(s => (s.monthly || 0) > 0)
.map(s => ({
label: s.label || 'Unnamed Stream',
monthly: s.monthly || 0,
pct: total > 0 ? (s.monthly || 0) / total : 0
}))
.sort((a, b) => b.monthly - a.monthly)
} catch (e) {
console.warn('Error calculating revenue mix:', e)
return []
}
}
// Concentration percentage (highest stream)
function concentrationPct(): number {
try {
const mix = revenueMix()
return mix.length > 0 ? (mix[0].pct || 0) : 0
} catch (e) {
console.warn('Error calculating concentration:', e)
return 0
}
}
// Runway calculation
function runwayMonths(mode?: 'min' | 'target', opts?: { useStress?: boolean }): number {
try {
const inputMode = mode || operatingMode.value
// Map to internal store format for compatibility
const currentMode = inputMode === 'min' ? 'minimum' : inputMode === 'target' ? 'target' : 'minimum'
const { members: scenarioMembers, streams: scenarioStreams } = opts?.useStress
? getStressedData()
: getScenarioData()
// Calculate monthly payroll
const payrollCost = monthlyPayroll(scenarioMembers || [], currentMode) || 0
const oncostPct = store.payrollOncostPct || 0
const totalPayroll = payrollCost * (1 + Math.max(0, oncostPct) / 100)
// Calculate revenue and costs
const totalRevenue = (scenarioStreams || []).reduce((sum, s) => sum + (s.monthly || 0), 0)
const overheadCost = (store.overheadCosts || []).reduce((sum, cost) => sum + (cost.amount || 0), 0)
// Apply cost shock if in stress mode
const adjustedOverhead = opts?.useStress && stress.value.costShockPct > 0
? overheadCost * (1 + Math.max(0, stress.value.costShockPct) / 100)
: overheadCost
// Monthly net and burn
const monthlyNet = totalRevenue - totalPayroll - adjustedOverhead
const monthlyBurn = totalPayroll + adjustedOverhead
// Cash reserves with safe defaults
const cash = Math.max(0, store.currentCash || 50000)
const savings = Math.max(0, store.currentSavings || 15000)
const totalLiquid = cash + savings
// Runway calculation
if (monthlyNet >= 0) {
return Infinity // Sustainable
}
return monthlyBurn > 0 ? totalLiquid / monthlyBurn : 0
} catch (e) {
console.warn('Error calculating runway:', e)
return 0
}
}
// Milestone status
function milestoneStatus(mode?: 'min' | 'target') {
const currentMode = mode || operatingMode.value
const runway = runwayMonths(currentMode)
const runwayEndDate = new Date()
runwayEndDate.setMonth(runwayEndDate.getMonth() + Math.floor(runway))
return milestones.value.map(milestone => ({
...milestone,
willReach: new Date(milestone.date) <= runwayEndDate
}))
}
// Actions
function setOperatingMode(mode: 'min' | 'target') {
store.setOperatingMode(mode)
}
function setScenario(newScenario: 'current' | 'quit-jobs' | 'start-production' | 'custom') {
store.setScenario(newScenario)
}
function updateStress(newStress: Partial<typeof stress.value>) {
store.updateStress(newStress)
}
function addMilestone(label: string, date: string) {
store.addMilestone(label, date)
}
function removeMilestone(id: string) {
store.removeMilestone(id)
}
// Testing helpers
function clearAll() {
store.clearAll()
}
function loadDefaults() {
store.loadDefaultData()
}
// Reset helper function
function reset() {
store.clearAll()
store.loadDefaultData()
}
return {
// State
members,
streams,
policy,
operatingMode,
scenario,
stress,
milestones,
// Computed
allocatePayroll,
coverage,
teamCoverageStats,
revenueMix,
concentrationPct,
runwayMonths,
milestoneStatus,
// Actions
setOperatingMode,
setScenario,
updateStress,
addMilestone,
removeMilestone,
upsertMember: (member: any) => store.upsertMember(member),
removeMember: (id: string) => store.removeMember(id),
upsertStream: (stream: any) => store.upsertStream(stream),
removeStream: (id: string) => store.removeStream(id),
setPolicy: (relationship: "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded") => store.setPolicy(relationship),
setRoleBands: (bands: Record<string, number>) => store.setRoleBands(bands),
setEqualWage: (wage: number) => store.setEqualWage(wage),
// Testing helpers
clearAll,
loadDefaults,
reset
}
}

View file

@ -0,0 +1,92 @@
import { monthlyPayroll } from '~/types/members'
export function useCushionForecast() {
const cashStore = useCashStore()
const membersStore = useMembersStore()
const policiesStore = usePoliciesStore()
const budgetStore = useBudgetStore()
// Savings progress calculation
const savingsProgress = computed(() => {
const current = cashStore.currentSavings || 0
const targetMonths = policiesStore.savingsTargetMonths || 3
const monthlyBurn = getMonthlyBurn()
const target = targetMonths * monthlyBurn
return {
current,
target,
targetMonths,
progressPct: target > 0 ? Math.min(100, (current / target) * 100) : 0,
gap: Math.max(0, target - current),
status: getProgressStatus(current, target)
}
})
// 13-week cushion breach forecast
const cushionForecast = computed(() => {
const minCushion = policiesStore.minCashCushionAmount || 5000
const currentBalance = cashStore.currentCash || 0
const monthlyBurn = getMonthlyBurn()
const weeklyBurn = monthlyBurn / 4.33 // Convert monthly to weekly
const weeks = []
let runningBalance = currentBalance
for (let week = 1; week <= 13; week++) {
// Simple projection: subtract weekly burn
runningBalance -= weeklyBurn
const breachesCushion = runningBalance < minCushion
weeks.push({
week,
balance: runningBalance,
breachesCushion,
cushionAmount: minCushion
})
}
const firstBreachWeek = weeks.find(w => w.breachesCushion)?.week
const breachesWithin13Weeks = Boolean(firstBreachWeek)
return {
weeks,
firstBreachWeek,
breachesWithin13Weeks,
minCushion,
weeksUntilBreach: firstBreachWeek || null
}
})
function getMonthlyBurn() {
const operatingMode = policiesStore.operatingMode || 'minimum'
const payrollCost = monthlyPayroll(membersStore.members, operatingMode)
const oncostPct = policiesStore.payrollOncostPct || 0
const totalPayroll = payrollCost * (1 + oncostPct / 100)
const overheadCost = budgetStore.overheadCosts.reduce((sum, cost) => sum + (cost.amount || 0), 0)
return totalPayroll + overheadCost
}
function getProgressStatus(current: number, target: number): 'green' | 'yellow' | 'red' {
if (target === 0) return 'green'
const pct = (current / target) * 100
if (pct >= 80) return 'green'
if (pct >= 50) return 'yellow'
return 'red'
}
// Alert conditions (matching CLAUDE.md)
const alerts = computed(() => ({
savingsBelowTarget: savingsProgress.value.current < savingsProgress.value.target,
cushionBreach: cushionForecast.value.breachesWithin13Weeks,
}))
return {
savingsProgress,
cushionForecast,
alerts,
getMonthlyBurn
}
}

View file

@ -33,10 +33,13 @@ export function useFixtureIO() {
payrollOncostPct: policies.payrollOncostPct,
savingsTargetMonths: policies.savingsTargetMonths,
minCashCushionAmount: policies.minCashCushionAmount,
operatingMode: policies.operatingMode,
payPolicy: policies.payPolicy,
deferredCapHoursPerQtr: policies.deferredCapHoursPerQtr,
deferredSunsetMonths: policies.deferredSunsetMonths,
surplusOrder: policies.surplusOrder,
paymentPriority: policies.paymentPriority
paymentPriority: policies.paymentPriority,
volunteerScope: policies.volunteerScope
},
streams: streams.streams,
budget: {
@ -66,9 +69,65 @@ export function useFixtureIO() {
}
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)
const members = useMembersStore()
const policies = usePoliciesStore()
const streams = useStreamsStore()
const budget = useBudgetStore()
const scenarios = useScenariosStore()
const cash = useCashStore()
const session = useSessionStore()
try {
// Import members
if (snapshot.members && Array.isArray(snapshot.members)) {
members.$patch({ members: snapshot.members })
}
// Import policies
if (snapshot.policies) {
policies.$patch(snapshot.policies)
}
// Import streams
if (snapshot.streams && Array.isArray(snapshot.streams)) {
streams.$patch({ streams: snapshot.streams })
}
// Import budget
if (snapshot.budget) {
budget.$patch(snapshot.budget)
}
// Import scenarios
if (snapshot.scenarios) {
scenarios.$patch(snapshot.scenarios)
}
// Import cash
if (snapshot.cash) {
cash.updateCurrentBalances(
snapshot.cash.currentCash || 0,
snapshot.cash.currentSavings || 0
)
// Handle cash events and payment queue if present
if (snapshot.cash.cashEvents) {
cash.$patch({ cashEvents: snapshot.cash.cashEvents })
}
if (snapshot.cash.paymentQueue) {
cash.$patch({ paymentQueue: snapshot.cash.paymentQueue })
}
}
// Import session
if (snapshot.session) {
session.$patch(snapshot.session)
}
console.log('Successfully imported data snapshot')
} catch (error) {
console.error('Failed to import snapshot:', error)
throw error
}
}
return { exportAll, importAll }

View file

@ -1,238 +1,73 @@
/**
* Composable for loading and managing fixture data
* Provides centralized access to demo data for all screens
* DISABLED: All fixture data removed to prevent automatic demo data
* This composable previously loaded sample data but is now empty
*/
export const useFixtures = () => {
// Load fixture data (in real app, this would come from API or stores)
// All sample data functions now return empty data
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
}
]
members: []
}
}
const loadStreams = async () => {
return {
revenueStreams: [
{
id: 'stream-1',
name: 'Client Services',
category: 'Services',
subcategory: 'Development',
targetPct: 65,
targetMonthlyAmount: 13000,
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: 4000,
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: 2000,
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: 600,
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: 400,
certainty: 'Probable',
payoutDelayDays: 21,
terms: 'Net 21',
revenueSharePct: 0,
platformFeePct: 0,
restrictions: 'General',
effortHoursPerMonth: 12
}
]
revenueStreams: []
}
}
const loadFinances = async () => {
return {
currentBalances: {
cash: 5000,
savings: 8000,
totalLiquid: 13000
cash: 0,
savings: 0,
totalLiquid: 0
},
policies: {
equalHourlyWage: 20,
payrollOncostPct: 25,
savingsTargetMonths: 3,
minCashCushionAmount: 3000,
deferredCapHoursPerQtr: 240,
deferredSunsetMonths: 12
equalHourlyWage: 0,
payrollOncostPct: 0,
savingsTargetMonths: 0,
minCashCushionAmount: 0,
deferredCapHoursPerQtr: 0,
deferredSunsetMonths: 0
},
deferredLiabilities: {
totalDeferred: 2340,
byMember: {
'member-1': 1700,
'member-2': 0,
'member-3': 640
}
totalDeferred: 0,
byMember: {}
}
}
}
const loadCosts = async () => {
return {
overheadCosts: [
{
id: 'overhead-1',
name: 'Coworking Space',
amount: 0,
category: 'Workspace',
recurring: true
},
{
id: 'overhead-2',
name: 'Tools & Software',
amount: 0,
category: 'Technology',
recurring: true
},
{
id: 'overhead-3',
name: 'Business Insurance',
amount: 0,
category: 'Legal & Compliance',
recurring: true
}
],
productionCosts: [
{
id: 'production-1',
name: 'Development Kits',
amount: 0,
category: 'Hardware',
period: '2024-01'
}
]
overheadCosts: [],
productionCosts: []
}
}
// Calculate derived metrics from fixture data
// Return empty metrics
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
totalTargetHours: 0,
totalTargetRevenue: 0,
monthlyPayroll: 0,
monthlyBurn: 0,
runway: 0,
members: [],
streams: [],
finances: {
currentBalances: { cash: 0, savings: 0, totalLiquid: 0 },
policies: {
equalHourlyWage: 0,
payrollOncostPct: 0,
savingsTargetMonths: 0,
minCashCushionAmount: 0,
deferredCapHoursPerQtr: 0,
deferredSunsetMonths: 0
},
deferredLiabilities: { totalDeferred: 0, byMember: {} }
},
costs: { overheadCosts: [], productionCosts: [] }
}
}

View file

@ -0,0 +1,142 @@
export function useMigrations() {
const CURRENT_SCHEMA_VERSION = "1.1"
const SCHEMA_KEY = "urgent-tools-schema-version"
// Get stored schema version
function getStoredVersion(): string {
if (process.client) {
return localStorage.getItem(SCHEMA_KEY) || "1.0"
}
return "1.0"
}
// Set schema version
function setVersion(version: string) {
if (process.client) {
localStorage.setItem(SCHEMA_KEY, version)
}
}
// Migration functions
const migrations = {
"1.0": () => {
// Initial schema - no migration needed
},
"1.1": () => {
// Add new member needs fields
const membersData = localStorage.getItem("urgent-tools-members")
if (membersData) {
try {
const parsed = JSON.parse(membersData)
if (Array.isArray(parsed.members)) {
// Add default values for new fields
parsed.members = parsed.members.map((member: any) => ({
...member,
minMonthlyNeeds: member.minMonthlyNeeds || 0,
targetMonthlyPay: member.targetMonthlyPay || 0,
externalMonthlyIncome: member.externalMonthlyIncome || 0,
monthlyPayPlanned: member.monthlyPayPlanned || 0,
}))
localStorage.setItem("urgent-tools-members", JSON.stringify(parsed))
}
} catch (error) {
console.warn("Failed to migrate members data:", error)
}
}
// Add new policy fields
const policiesData = localStorage.getItem("urgent-tools-policies")
if (policiesData) {
try {
const parsed = JSON.parse(policiesData)
parsed.operatingMode = parsed.operatingMode || 'minimum'
parsed.payPolicy = parsed.payPolicy || {
relationship: 'equal-pay',
roleBands: []
}
localStorage.setItem("urgent-tools-policies", JSON.stringify(parsed))
} catch (error) {
console.warn("Failed to migrate policies data:", error)
}
}
// DISABLED: No automatic cash balance initialization
// The app should start completely empty - no demo data
//
// Previously this would auto-initialize cash balances, but this
// created unwanted demo data. Users must explicitly set up their data.
//
// const cashData = localStorage.getItem("urgent-tools-cash")
// if (!cashData) {
// localStorage.setItem("urgent-tools-cash", JSON.stringify({
// currentCash: 50000,
// currentSavings: 15000,
// cashEvents: [],
// paymentQueue: []
// }))
// }
}
}
// Run all necessary migrations
function migrate() {
if (!process.client) return
const currentVersion = getStoredVersion()
if (currentVersion === CURRENT_SCHEMA_VERSION) {
return // Already up to date
}
console.log(`Migrating from schema version ${currentVersion} to ${CURRENT_SCHEMA_VERSION}`)
// Run migrations in order
const versions = Object.keys(migrations).sort()
const currentIndex = versions.indexOf(currentVersion)
if (currentIndex === -1) {
console.warn(`Unknown schema version: ${currentVersion}, running all migrations`)
// Run all migrations
versions.forEach(version => {
try {
migrations[version as keyof typeof migrations]()
console.log(`Applied migration: ${version}`)
} catch (error) {
console.error(`Migration ${version} failed:`, error)
}
})
} else {
// Run migrations from current version + 1 to latest
for (let i = currentIndex + 1; i < versions.length; i++) {
const version = versions[i]
try {
migrations[version as keyof typeof migrations]()
console.log(`Applied migration: ${version}`)
} catch (error) {
console.error(`Migration ${version} failed:`, error)
}
}
}
// Update stored version
setVersion(CURRENT_SCHEMA_VERSION)
console.log(`Migration complete. Schema version: ${CURRENT_SCHEMA_VERSION}`)
}
// Check if migration is needed
function needsMigration(): boolean {
// Don't run migrations if data was intentionally cleared
if (process.client && localStorage.getItem('urgent-tools-cleared-flag') === 'true') {
return false
}
return getStoredVersion() !== CURRENT_SCHEMA_VERSION
}
return {
migrate,
needsMigration,
currentVersion: CURRENT_SCHEMA_VERSION,
storedVersion: getStoredVersion()
}
}

View file

@ -0,0 +1,52 @@
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { allocatePayroll } from '~/types/members'
export function usePayrollAllocation() {
const membersStore = useMembersStore()
const policiesStore = usePoliciesStore()
const budgetStore = useBudgetStore()
const { members, payPolicy } = storeToRefs(membersStore)
const { equalHourlyWage, payrollOncostPct } = storeToRefs(policiesStore)
const { capacityTotals } = storeToRefs(membersStore)
// Calculate base payroll budget from hours and wage
const basePayrollBudget = computed(() => {
const totalHours = capacityTotals.value.targetHours || 0
const wage = equalHourlyWage.value || 0
return totalHours * wage
})
// Allocate payroll to members based on policy
const allocatedMembers = computed(() => {
if (members.value.length === 0 || basePayrollBudget.value === 0) {
return members.value
}
return allocatePayroll(
members.value,
payPolicy.value,
basePayrollBudget.value
)
})
// Total payroll with oncosts
const totalPayrollWithOncosts = computed(() => {
return basePayrollBudget.value * (1 + payrollOncostPct.value / 100)
})
// Update member planned pay when allocation changes
watchEffect(() => {
allocatedMembers.value.forEach(member => {
membersStore.setPlannedPay(member.id, member.monthlyPayPlanned || 0)
})
})
return {
basePayrollBudget,
allocatedMembers,
totalPayrollWithOncosts,
payPolicy
}
}

View file

@ -1,13 +1,49 @@
import { monthlyPayroll } from '~/types/members'
/**
* Computes months of runway from cash, reserves, and burn rate
* Formula: (cash + savings) ÷ average monthly burn in scenario
*/
export const useRunway = () => {
const membersStore = useMembersStore()
const policiesStore = usePoliciesStore()
const budgetStore = useBudgetStore()
const calculateRunway = (cash: number, savings: number, monthlyBurn: number): number => {
if (monthlyBurn <= 0) return Infinity
return (cash + savings) / monthlyBurn
}
// Calculate monthly burn based on operating mode
const getMonthlyBurn = (mode?: 'minimum' | 'target') => {
const operatingMode = mode || policiesStore.operatingMode || 'minimum'
// Get payroll costs based on mode
const payrollCost = monthlyPayroll(membersStore.members, operatingMode)
// Add oncosts
const oncostPct = policiesStore.payrollOncostPct || 0
const totalPayroll = payrollCost * (1 + oncostPct / 100)
// Add overhead costs
const overheadCost = budgetStore.overheadCosts.reduce((sum, cost) => sum + (cost.amount || 0), 0)
return totalPayroll + overheadCost
}
// Calculate runway for both modes
const getDualModeRunway = (cash: number, savings: number) => {
const minBurn = getMonthlyBurn('minimum')
const targetBurn = getMonthlyBurn('target')
return {
minimum: calculateRunway(cash, savings, minBurn),
target: calculateRunway(cash, savings, targetBurn),
minBurn,
targetBurn
}
}
const getRunwayStatus = (months: number): 'green' | 'yellow' | 'red' => {
if (months >= 3) return 'green'
if (months >= 2) return 'yellow'
@ -22,6 +58,8 @@ export const useRunway = () => {
return {
calculateRunway,
getRunwayStatus,
formatRunway
formatRunway,
getMonthlyBurn,
getDualModeRunway
}
}

109
composables/useScenarios.ts Normal file
View file

@ -0,0 +1,109 @@
import { monthlyPayroll } from '~/types/members'
export function useScenarios() {
const membersStore = useMembersStore()
const streamsStore = useStreamsStore()
const policiesStore = usePoliciesStore()
const budgetStore = useBudgetStore()
const cashStore = useCashStore()
// Base runway calculation
function calculateScenarioRunway(
members: any[],
streams: any[],
operatingMode: 'minimum' | 'target' = 'minimum'
) {
// Calculate payroll for scenario
const payrollCost = monthlyPayroll(members, operatingMode)
const oncostPct = policiesStore.payrollOncostPct || 0
const totalPayroll = payrollCost * (1 + oncostPct / 100)
// Calculate revenue
const totalRevenue = streams.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0)
// Add overhead
const overheadCost = budgetStore.overheadCosts.reduce((sum, cost) => sum + (cost.amount || 0), 0)
// Net monthly
const monthlyNet = totalRevenue - totalPayroll - overheadCost
// Cash + savings
const cash = cashStore.currentCash || 50000
const savings = cashStore.currentSavings || 15000
const totalLiquid = cash + savings
// Runway calculation
const monthlyBurn = totalPayroll + overheadCost
const runway = monthlyBurn > 0 ? totalLiquid / monthlyBurn : Infinity
return {
runway: Math.max(0, runway),
monthlyNet,
monthlyBurn,
totalRevenue,
totalPayroll
}
}
// Scenario transformations per CLAUDE.md
const scenarioTransforms = {
current: () => ({
members: [...membersStore.members],
streams: [...streamsStore.streams]
}),
quitJobs: () => ({
// Set external income to 0 for members who have day jobs
members: membersStore.members.map(m => ({
...m,
externalMonthlyIncome: 0 // Assume everyone quits their day job
})),
streams: [...streamsStore.streams]
}),
startProduction: () => ({
members: [...membersStore.members],
// Reduce service revenue, increase production costs
streams: streamsStore.streams.map(s => {
// Reduce service contracts by 30%
if (s.category?.toLowerCase().includes('service') || s.name.toLowerCase().includes('service')) {
return { ...s, targetMonthlyAmount: (s.targetMonthlyAmount || 0) * 0.7 }
}
return s
})
})
}
// Calculate all scenarios
const scenarios = computed(() => {
const currentMode = policiesStore.operatingMode || 'minimum'
const current = scenarioTransforms.current()
const quitJobs = scenarioTransforms.quitJobs()
const startProduction = scenarioTransforms.startProduction()
return {
current: {
name: 'Operate Current',
status: 'Active',
...calculateScenarioRunway(current.members, current.streams, currentMode)
},
quitJobs: {
name: 'Quit Day Jobs',
status: 'Scenario',
...calculateScenarioRunway(quitJobs.members, quitJobs.streams, currentMode)
},
startProduction: {
name: 'Start Production',
status: 'Scenario',
...calculateScenarioRunway(startProduction.members, startProduction.streams, currentMode)
}
}
})
return {
scenarios,
calculateScenarioRunway,
scenarioTransforms
}
}