refactor: remove deprecated components and streamline member coverage calculations, enhance budget management with improved payroll handling, and update UI elements for better clarity

This commit is contained in:
Jennie Robinson Faber 2025-09-06 09:48:57 +01:00
parent 983aeca2dc
commit 09d8794d72
42 changed files with 2166 additions and 2974 deletions

View file

@ -60,6 +60,24 @@ export function useCoopBuilder() {
}
})
const currency = computed({
get: () => {
try {
return store.currency || 'EUR'
} catch (e) {
console.warn('Error accessing currency:', e)
return 'EUR'
}
},
set: (value: string) => {
try {
store.setCurrency(value)
} catch (e) {
console.warn('Error setting currency:', e)
}
}
})
const scenario = computed({
get: () => store.scenario,
set: (value) => store.setScenario(value)
@ -78,12 +96,6 @@ export function useCoopBuilder() {
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,
@ -154,25 +166,21 @@ export function useCoopBuilder() {
}
// Coverage calculation for a single member
function coverage(member: Member): { minPct: number; targetPct: number } {
const totalIncome = (member.monthlyPayPlanned || 0) + (member.externalMonthlyIncome || 0)
function coverage(member: Member): { coveragePct: number } {
const coopPay = member.monthlyPayPlanned || 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)
const coveragePct = member.minMonthlyNeeds > 0
? Math.min(200, (coopPay / member.minMonthlyNeeds) * 100)
: 100
return { minPct, targetPct }
return { coveragePct }
}
// Team coverage statistics
function teamCoverageStats() {
try {
const allocatedMembers = allocatePayroll() || []
const coverages = allocatedMembers.map(m => coverage(m).minPct).filter(c => !isNaN(c))
const coverages = allocatedMembers.map(m => coverage(m).coveragePct).filter(c => !isNaN(c))
if (coverages.length === 0) {
return { median: 0, under100: 0, over100Pct: 0, gini: 0 }
@ -354,6 +362,7 @@ export function useCoopBuilder() {
streams,
policy,
operatingMode,
currency,
scenario,
stress,
milestones,
@ -380,6 +389,7 @@ export function useCoopBuilder() {
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),
setCurrency: (currency: string) => store.setCurrency(currency),
// Testing helpers
clearAll,

View file

@ -0,0 +1,37 @@
import { getCurrencySymbol } from '~/utils/currency'
export function useCurrency() {
const coop = useCoopBuilder()
const currencySymbol = computed(() => getCurrencySymbol(coop.currency.value))
const formatCurrency = (amount: number, options?: { showSymbol?: boolean; precision?: number }) => {
const { showSymbol = true, precision = 0 } = options || {}
const formatted = new Intl.NumberFormat('en-US', {
minimumFractionDigits: precision,
maximumFractionDigits: precision
}).format(amount)
if (showSymbol) {
return `${currencySymbol.value}${formatted}`
}
return formatted
}
const formatCurrencyCompact = (amount: number) => {
if (amount >= 1000000) {
return `${currencySymbol.value}${(amount / 1000000).toFixed(1)}M`
} else if (amount >= 1000) {
return `${currencySymbol.value}${(amount / 1000).toFixed(1)}k`
}
return formatCurrency(amount)
}
return {
currencySymbol,
formatCurrency,
formatCurrencyCompact
}
}

View file

@ -2,18 +2,14 @@ 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() {
@ -22,9 +18,7 @@ export function useFixtureIO() {
const policies = usePoliciesStore()
const streams = useStreamsStore()
const budget = useBudgetStore()
const scenarios = useScenariosStore()
const cash = useCashStore()
const session = useSessionStore()
return {
members: members.members,
@ -48,23 +42,12 @@ export function useFixtureIO() {
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
}
}
}
@ -73,9 +56,7 @@ export function useFixtureIO() {
const policies = usePoliciesStore()
const streams = useStreamsStore()
const budget = useBudgetStore()
const scenarios = useScenariosStore()
const cash = useCashStore()
const session = useSessionStore()
try {
// Import members
@ -98,10 +79,6 @@ export function useFixtureIO() {
budget.$patch(snapshot.budget)
}
// Import scenarios
if (snapshot.scenarios) {
scenarios.$patch(snapshot.scenarios)
}
// Import cash
if (snapshot.cash) {
@ -118,10 +95,6 @@ export function useFixtureIO() {
}
}
// Import session
if (snapshot.session) {
session.$patch(snapshot.session)
}
console.log('Successfully imported data snapshot')
} catch (error) {

View file

@ -1,109 +0,0 @@
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
}
}

View file

@ -0,0 +1,83 @@
/**
* Setup State Management
*
* Provides utilities to determine setup completion status and manage
* field locking based on setup state
*/
export const useSetupState = () => {
const coopStore = useCoopBuilderStore()
const membersStore = useMembersStore()
const policiesStore = usePoliciesStore()
const streamsStore = useStreamsStore()
// Check if setup is complete using the same logic as middleware
const isSetupComplete = computed(() => {
// Legacy stores OR new coop builder store (either is enough)
const legacyComplete =
membersStore.isValid &&
policiesStore.isValid &&
streamsStore.hasValidStreams
const coopComplete = Boolean(
coopStore &&
Array.isArray(coopStore.members) &&
coopStore.members.length > 0 &&
Array.isArray(coopStore.streams) &&
coopStore.streams.length > 0
)
return legacyComplete || coopComplete
})
// Determine if revenue and expense fields should be locked
const areRevenueFieldsLocked = computed(() => {
return isSetupComplete.value
})
const areExpenseFieldsLocked = computed(() => {
return isSetupComplete.value
})
// Determine if member management should be in separate interface
const shouldUseSeparateMemberInterface = computed(() => {
return isSetupComplete.value
})
// Get setup completion percentage for progress display
const setupProgress = computed(() => {
let completed = 0
let total = 4 // policies, members, revenue, costs
if (policiesStore.isValid || (coopStore.equalHourlyWage > 0)) completed++
if (membersStore.members.length > 0 || coopStore.members.length > 0) completed++
if (streamsStore.hasValidStreams || coopStore.streams.length > 0) completed++
if (coopStore.overheadCosts.length > 0) completed++
return Math.round((completed / total) * 100)
})
// Navigation helpers
const goToSetup = () => {
navigateTo('/coop-planner')
}
const goToMemberManagement = () => {
navigateTo('/settings#members')
}
const goToRevenueMix = () => {
navigateTo('/mix')
}
return {
isSetupComplete,
areRevenueFieldsLocked,
areExpenseFieldsLocked,
shouldUseSeparateMemberInterface,
setupProgress,
goToSetup,
goToMemberManagement,
goToRevenueMix
}
}

260
composables/useStorSync.ts Normal file
View file

@ -0,0 +1,260 @@
/**
* Store Synchronization Composable
*
* Ensures that the legacy stores (streams, members, policies) always stay
* synchronized with the new CoopBuilderStore. This makes the setup interface
* the single source of truth while maintaining backward compatibility.
*/
export const useStoreSync = () => {
const coopStore = useCoopBuilderStore()
const streamsStore = useStreamsStore()
const membersStore = useMembersStore()
const policiesStore = usePoliciesStore()
// Flags to prevent recursive syncing and duplicate watchers
let isSyncing = false
let watchersSetup = false
// Sync CoopBuilder -> Legacy Stores
const syncToLegacyStores = () => {
if (isSyncing) return
isSyncing = true
// Sync streams
streamsStore.resetStreams()
coopStore.streams.forEach((stream: any) => {
streamsStore.upsertStream({
id: stream.id,
name: stream.label,
category: stream.category || 'services',
targetMonthlyAmount: stream.monthly,
certainty: stream.certainty || 'Probable',
payoutDelayDays: 30,
terms: 'Net 30',
targetPct: 0,
revenueSharePct: 0,
platformFeePct: 0,
restrictions: 'General',
seasonalityWeights: new Array(12).fill(1),
effortHoursPerMonth: 0
})
})
// Sync members
membersStore.resetMembers()
coopStore.members.forEach((member: any) => {
membersStore.upsertMember({
id: member.id,
displayName: member.name,
role: member.role || '',
hoursPerWeek: Math.round((member.hoursPerMonth || 0) / 4.33),
minMonthlyNeeds: member.minMonthlyNeeds || 0,
monthlyPayPlanned: member.monthlyPayPlanned || 0,
targetMonthlyPay: member.targetMonthlyPay || 0,
externalMonthlyIncome: member.externalMonthlyIncome || 0
})
})
// Sync policies - using individual update calls based on store structure
policiesStore.updatePolicy('equalHourlyWage', coopStore.equalHourlyWage)
policiesStore.updatePolicy('payrollOncostPct', coopStore.payrollOncostPct)
policiesStore.updatePolicy('savingsTargetMonths', coopStore.savingsTargetMonths)
policiesStore.updatePolicy('minCashCushionAmount', coopStore.minCashCushion)
// Reset flag after sync completes
nextTick(() => {
isSyncing = false
})
}
// Sync Legacy Stores -> CoopBuilder
const syncFromLegacyStores = () => {
if (isSyncing) return
isSyncing = true
// Sync streams from legacy store
streamsStore.streams.forEach((stream: any) => {
coopStore.upsertStream({
id: stream.id,
label: stream.name,
monthly: stream.targetMonthlyAmount,
category: stream.category,
certainty: stream.certainty
})
})
// Sync members from legacy store
membersStore.members.forEach((member: any) => {
coopStore.upsertMember({
id: member.id,
name: member.displayName,
role: member.role,
hoursPerMonth: Math.round((member.hoursPerWeek || 0) * 4.33),
minMonthlyNeeds: member.minMonthlyNeeds,
monthlyPayPlanned: member.monthlyPayPlanned,
targetMonthlyPay: member.targetMonthlyPay,
externalMonthlyIncome: member.externalMonthlyIncome
})
})
// Sync policies from legacy store
if (policiesStore.isValid) {
coopStore.setEqualWage(policiesStore.equalHourlyWage)
coopStore.setOncostPct(policiesStore.payrollOncostPct)
coopStore.savingsTargetMonths = policiesStore.savingsTargetMonths
coopStore.minCashCushion = policiesStore.minCashCushionAmount
if (policiesStore.payPolicy?.relationship) {
coopStore.setPolicy(policiesStore.payPolicy.relationship as any)
}
}
// Reset flag after sync completes
nextTick(() => {
isSyncing = false
})
}
// Watch for changes in CoopBuilder and sync to legacy stores
const setupCoopBuilderWatchers = () => {
// Watch streams changes
watch(() => coopStore.streams, () => {
if (!isSyncing) {
syncToLegacyStores()
}
}, { deep: true })
// Watch members changes
watch(() => coopStore.members, () => {
if (!isSyncing) {
syncToLegacyStores()
}
}, { deep: true })
// Watch policy changes
watch(() => [
coopStore.equalHourlyWage,
coopStore.payrollOncostPct,
coopStore.savingsTargetMonths,
coopStore.minCashCushion,
coopStore.currency,
coopStore.policy.relationship
], () => {
if (!isSyncing) {
syncToLegacyStores()
}
})
}
// Watch for changes in legacy stores and sync to CoopBuilder
const setupLegacyStoreWatchers = () => {
// Watch streams store changes
watch(() => streamsStore.streams, () => {
if (!isSyncing) {
syncFromLegacyStores()
}
}, { deep: true })
// Watch members store changes
watch(() => membersStore.members, () => {
if (!isSyncing) {
syncFromLegacyStores()
}
}, { deep: true })
// Watch policies store changes
watch(() => [
policiesStore.equalHourlyWage,
policiesStore.payrollOncostPct,
policiesStore.savingsTargetMonths,
policiesStore.minCashCushionAmount,
policiesStore.payPolicy?.relationship
], () => {
if (!isSyncing) {
syncFromLegacyStores()
}
})
}
// Initialize synchronization
const initSync = async () => {
// Wait for next tick to ensure stores are mounted
await nextTick()
// Force store hydration by accessing $state
if (coopStore.$state) {
console.log('🔄 CoopBuilder store hydrated')
}
// Small delay to ensure localStorage is loaded
await new Promise(resolve => setTimeout(resolve, 10))
// Determine which store has data and sync accordingly
const coopHasData = coopStore.members.length > 0 || coopStore.streams.length > 0
const legacyHasData = streamsStore.streams.length > 0 || membersStore.members.length > 0
console.log('🔄 InitSync: CoopBuilder data:', coopHasData, 'Legacy data:', legacyHasData)
console.log('🔄 CoopBuilder members:', coopStore.members.length, 'streams:', coopStore.streams.length)
console.log('🔄 Legacy members:', membersStore.members.length, 'streams:', streamsStore.streams.length)
if (coopHasData && !legacyHasData) {
console.log('🔄 Syncing CoopBuilder → Legacy')
syncToLegacyStores()
} else if (legacyHasData && !coopHasData) {
console.log('🔄 Syncing Legacy → CoopBuilder')
syncFromLegacyStores()
} else if (coopHasData && legacyHasData) {
console.log('🔄 Both have data, keeping in sync')
// Both have data, ensure consistency by syncing from CoopBuilder (primary source)
syncToLegacyStores()
} else {
console.log('🔄 No data in either store')
}
// Set up watchers for ongoing sync (only once)
if (!watchersSetup) {
setupCoopBuilderWatchers()
setupLegacyStoreWatchers()
watchersSetup = true
}
// Return promise to allow awaiting
return Promise.resolve()
}
// Get unified streams data (prioritize CoopBuilder) - make reactive
const unifiedStreams = computed(() => {
if (coopStore.streams.length > 0) {
return coopStore.streams.map(stream => ({
...stream,
name: stream.label,
targetMonthlyAmount: stream.monthly
}))
}
return streamsStore.streams
})
// Get unified members data (prioritize CoopBuilder) - make reactive
const unifiedMembers = computed(() => {
if (coopStore.members.length > 0) {
return coopStore.members.map(member => ({
...member,
displayName: member.name,
hoursPerWeek: Math.round((member.hoursPerMonth || 0) / 4.33)
}))
}
return membersStore.members
})
// Getter functions for backward compatibility
const getStreams = () => unifiedStreams.value
const getMembers = () => unifiedMembers.value
return {
syncToLegacyStores,
syncFromLegacyStores,
initSync,
getStreams,
getMembers,
unifiedStreams,
unifiedMembers
}
}