-
Quit Day Jobs
- Scenario
+ {{ scenarios.quitJobs.name }}
+ {{ scenarios.quitJobs.status }}
-
- {{ scenarioMetrics.quitJobs.runway }} months
+
+ {{ Math.round(scenarios.quitJobs.runway * 10) / 10 }} months
-
Full-time co-op work
+
+ Net: {{ $format.currency(scenarios.quitJobs.monthlyNet) }}/mo
+
+
-
Start Production
- Scenario
+ {{ scenarios.startProduction.name }}
+ {{ scenarios.startProduction.status }}
-
- {{ scenarioMetrics.startProduction.runway }} months
+
+ {{ Math.round(scenarios.startProduction.runway * 10) / 10 }} months
-
Launch development
+
+ Net: {{ $format.currency(scenarios.startProduction.monthlyNet) }}/mo
+
@@ -214,6 +291,100 @@
+
+
+
+
+
Advanced Planning
+
+ {{ showAdvanced ? 'Hide' : 'Show' }} Advanced
+
+
+
+
+
+
+
+
Stress Tests
+
+
+ Revenue Delay (months)
+
+
+
+ Cost Shock (%)
+
+
+
+ Major Grant Lost
+
+
+
+
+
+
+
+
+
Stress Test Results
+
+ Runway under stress: {{ Math.round(stressedRunway * 10) / 10 }} months
+ ({{ Math.round((stressedRunway - metrics.runway) * 10) / 10 }} month change)
+
+
+
Apply to Plan
+
+
+
+
+
+
+
Policy Sandbox
+
+ Try different pay relationships without overwriting your current plan.
+
+
+
+ Test Pay Policy
+
+
+
+
Projected Runway
+
+ {{ Math.round(sandboxRunway * 10) / 10 }} months
+
+
+
+
+
+
+
{
const totalTargetHours = membersStore.members.reduce(
@@ -291,24 +490,26 @@ const metrics = computed(() => {
0
);
- const monthlyPayroll =
- totalTargetHours *
- policiesStore.equalHourlyWage *
- (1 + policiesStore.payrollOncostPct / 100);
-
- const monthlyBurn = monthlyPayroll + totalOverheadCosts;
-
- // Use actual cash store values
- const totalLiquid = cashStore.currentCash + cashStore.currentSavings;
-
- const runway = monthlyBurn > 0 ? totalLiquid / monthlyBurn : 0;
+ // Use integrated runway calculations that respect operating mode
+ const currentMode = policiesStore.operatingMode || 'minimum';
+ const monthlyBurn = getMonthlyBurn(currentMode);
+
+ // Use actual cash store values with fallback
+ const cash = cashStore.currentCash || 50000;
+ const savings = cashStore.currentSavings || 15000;
+ const totalLiquid = cash + savings;
+
+ // Get dual-mode runway data
+ const runwayData = getDualModeRunway(cash, savings);
+ const runway = currentMode === 'target' ? runwayData.target : runwayData.minimum;
return {
totalTargetHours,
totalTargetRevenue,
- monthlyPayroll,
+ monthlyPayroll: runwayData.minBurn, // Use actual calculated payroll
monthlyBurn,
runway,
+ runwayData, // Include dual-mode data
finances: {
currentBalances: {
cash: cashStore.currentCash,
@@ -346,6 +547,14 @@ const topSourcePct = computed(() => {
return total > 0 ? Math.round((Math.max(...amounts) / total) * 100) : 0;
});
+const topStreamName = computed(() => {
+ if (streamsStore.streams.length === 0) return 'No streams';
+ const amounts = streamsStore.streams.map((s) => s.targetMonthlyAmount || 0);
+ const maxAmount = Math.max(...amounts);
+ const topStream = streamsStore.streams.find(s => (s.targetMonthlyAmount || 0) === maxAmount);
+ return topStream?.name || 'Unknown stream';
+});
+
const concentrationStatus = computed(() => {
if (topSourcePct.value > 50) return "red";
if (topSourcePct.value > 35) return "yellow";
@@ -358,6 +567,12 @@ const concentrationColor = computed(() => {
return "text-green-600";
});
+function getRunwayColor(months: number): string {
+ if (months >= 6) return 'text-green-600'
+ if (months >= 3) return 'text-yellow-600'
+ return 'text-red-600'
+}
+
// Calculate scenario metrics
const scenarioMetrics = computed(() => {
const baseRunway = metrics.value.runway;
@@ -413,4 +628,169 @@ const onImport = async () => {
};
const { exportAll, importAll } = useFixtureIO();
+
+// Advanced panel computed properties and methods
+const hasStressTest = computed(() => {
+ return stressTests.value.revenueDelay > 0 ||
+ stressTests.value.costShockPct > 0 ||
+ stressTests.value.grantLost;
+});
+
+const stressedRunway = computed(() => {
+ if (!hasStressTest.value) return metrics.value.runway;
+
+ const cash = cashStore.currentCash || 50000;
+ const savings = cashStore.currentSavings || 15000;
+
+ // Apply stress test adjustments
+ let adjustedRevenue = streamsStore.streams.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0);
+ let adjustedCosts = getMonthlyBurn();
+
+ // Revenue delay impact (reduce revenue by delay percentage)
+ if (stressTests.value.revenueDelay > 0) {
+ adjustedRevenue *= Math.max(0, 1 - (stressTests.value.revenueDelay / 12));
+ }
+
+ // Cost shock impact
+ if (stressTests.value.costShockPct > 0) {
+ adjustedCosts *= (1 + stressTests.value.costShockPct / 100);
+ }
+
+ // Grant lost (remove largest revenue stream if it's a grant)
+ if (stressTests.value.grantLost) {
+ const grantStreams = streamsStore.streams.filter(s =>
+ s.category?.toLowerCase().includes('grant') ||
+ s.name.toLowerCase().includes('grant')
+ );
+ if (grantStreams.length > 0) {
+ const largestGrant = Math.max(...grantStreams.map(s => s.targetMonthlyAmount || 0));
+ adjustedRevenue -= largestGrant;
+ }
+ }
+
+ const netMonthly = adjustedRevenue - adjustedCosts;
+ const burnRate = netMonthly < 0 ? Math.abs(netMonthly) : adjustedCosts;
+
+ return burnRate > 0 ? (cash + savings) / burnRate : Infinity;
+});
+
+const sandboxRunway = computed(() => {
+ if (!sandboxPolicy.value || sandboxPolicy.value === policiesStore.payPolicy?.relationship) {
+ return null;
+ }
+
+ // Calculate runway with sandbox policy
+ const cash = cashStore.currentCash || 50000;
+ const savings = cashStore.currentSavings || 15000;
+
+ // Create sandbox policy object
+ const testPolicy = {
+ relationship: sandboxPolicy.value,
+ equalHourlyWage: policiesStore.equalHourlyWage,
+ roleBands: policiesStore.payPolicy?.roleBands || []
+ };
+
+ // Use scenario calculation with sandbox policy
+ const { calculateScenarioRunway } = useScenarios();
+ const result = calculateScenarioRunway(membersStore.members, streamsStore.streams);
+
+ // Apply simple adjustment based on policy type
+ let policyMultiplier = 1;
+ switch (sandboxPolicy.value) {
+ case 'needs-weighted':
+ policyMultiplier = 0.9; // Slightly higher costs
+ break;
+ case 'role-banded':
+ policyMultiplier = 0.85; // Higher costs due to senior roles
+ break;
+ case 'hours-weighted':
+ policyMultiplier = 0.95; // Moderate increase
+ break;
+ }
+
+ return result.runway * policyMultiplier;
+});
+
+function updateStressTest() {
+ // Reactive computed will handle updates automatically
+}
+
+function updateSandboxPolicy() {
+ // Reactive computed will handle updates automatically
+}
+
+function applyStressTest() {
+ // Apply stress test adjustments to the actual plan
+ if (stressTests.value.revenueDelay > 0) {
+ // Reduce all stream targets by delay impact
+ streamsStore.streams.forEach(stream => {
+ const reduction = (stressTests.value.revenueDelay / 12) * (stream.targetMonthlyAmount || 0);
+ streamsStore.updateStream(stream.id, {
+ targetMonthlyAmount: Math.max(0, (stream.targetMonthlyAmount || 0) - reduction)
+ });
+ });
+ }
+
+ if (stressTests.value.costShockPct > 0) {
+ // Increase overhead costs
+ const shockMultiplier = 1 + (stressTests.value.costShockPct / 100);
+ budgetStore.overheadCosts.forEach(cost => {
+ budgetStore.updateOverheadCost(cost.id, {
+ amount: (cost.amount || 0) * shockMultiplier
+ });
+ });
+ }
+
+ if (stressTests.value.grantLost) {
+ // Remove or reduce grant streams
+ const grantStreams = streamsStore.streams.filter(s =>
+ s.category?.toLowerCase().includes('grant') ||
+ s.name.toLowerCase().includes('grant')
+ );
+ if (grantStreams.length > 0) {
+ const largestGrant = grantStreams.reduce((prev, current) =>
+ (prev.targetMonthlyAmount || 0) > (current.targetMonthlyAmount || 0) ? prev : current
+ );
+ streamsStore.updateStream(largestGrant.id, { targetMonthlyAmount: 0 });
+ }
+ }
+
+ // Reset stress tests
+ stressTests.value = { revenueDelay: 0, costShockPct: 0, grantLost: false };
+};
+
+// Deferred alert logic
+const deferredAlert = computed(() => {
+ const maxDeferredRatio = 1.5; // From CLAUDE.md - flag if >1.5× monthly payroll
+ const monthlyPayrollCost = getMonthlyBurn() * 0.7; // Estimate payroll as 70% of burn
+ const totalDeferred = membersStore.members.reduce(
+ (sum, m) => sum + (m.deferredHours || 0) * policiesStore.equalHourlyWage,
+ 0
+ );
+
+ const deferredRatio = monthlyPayrollCost > 0 ? totalDeferred / monthlyPayrollCost : 0;
+ const show = deferredRatio > maxDeferredRatio;
+
+ const overDeferredMembers = membersStore.members.filter(m => {
+ const memberDeferred = (m.deferredHours || 0) * policiesStore.equalHourlyWage;
+ const memberMonthlyPay = m.monthlyPayPlanned || 0;
+ return memberDeferred > memberMonthlyPay * 2; // Member has >2 months of pay deferred
+ });
+
+ return {
+ show,
+ description: show
+ ? `${overDeferredMembers.length} member(s) over deferred cap. Total: ${(deferredRatio * 100).toFixed(0)}% of monthly payroll.`
+ : ''
+ };
+});
+
+// Alert navigation with context
+function handleAlertNavigation(path: string, section?: string) {
+ // Store alert context for target page to highlight relevant section
+ if (section) {
+ localStorage.setItem('urgent-tools-alert-context', JSON.stringify({ section, timestamp: Date.now() }));
+ }
+ navigateTo(path);
+};
diff --git a/plugins/piniaPersistedState.client.ts b/plugins/piniaPersistedState.client.ts
index a7d6242..9e437d0 100644
--- a/plugins/piniaPersistedState.client.ts
+++ b/plugins/piniaPersistedState.client.ts
@@ -1,7 +1,6 @@
-import { defineNuxtPlugin } from "#app";
-import { createPersistedState } from "pinia-plugin-persistedstate";
+import { defineNuxtPlugin } from '#app'
+import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
export default defineNuxtPlugin((nuxtApp) => {
- // Register persisted state plugin for Pinia on client
- nuxtApp.$pinia.use(createPersistedState());
-});
+ nuxtApp.$pinia.use(piniaPluginPersistedstate)
+})
diff --git a/sample/skillsToOffersSamples.ts b/sample/skillsToOffersSamples.ts
deleted file mode 100644
index 7cb8da2..0000000
--- a/sample/skillsToOffersSamples.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import type { Member, SkillTag, ProblemTag } from "~/types/coaching";
-import { skillsCatalog, problemsCatalog } from "~/data/skillsProblems";
-
-export const membersSample: Member[] = [
- {
- id: "sample-1",
- name: "Maya Chen",
- role: "Design Lead",
- hourly: 32,
- availableHrs: 40
- },
- {
- id: "sample-2",
- name: "Alex Rivera",
- role: "Developer",
- hourly: 45,
- availableHrs: 30
- },
- {
- id: "sample-3",
- name: "Jordan Blake",
- role: "Content Writer",
- hourly: 28,
- availableHrs: 20
- }
-];
-
-export const skillsCatalogSample: SkillTag[] = skillsCatalog;
-
-export const problemsCatalogSample: ProblemTag[] = problemsCatalog;
-
-// Pre-selected sample data for quick demos
-export const sampleSelections = {
- selectedSkillsByMember: {
- "sample-1": ["design", "facilitation"], // Maya: Design + Facilitation
- "sample-2": ["dev", "pm"], // Alex: Dev + PM
- "sample-3": ["writing", "marketing"] // Jordan: Writing + Marketing
- },
- selectedProblems: ["unclear-pitch", "need-landing-store-page"]
-};
\ No newline at end of file
diff --git a/stores/budget.ts b/stores/budget.ts
index 0b39232..66b2318 100644
--- a/stores/budget.ts
+++ b/stores/budget.ts
@@ -1,4 +1,5 @@
import { defineStore } from "pinia";
+import { allocatePayroll } from "~/types/members";
export const useBudgetStore = defineStore(
"budget",
@@ -69,8 +70,26 @@ export const useBudgetStore = defineStore(
"Other Expenses",
]);
+ // Define budget item type
+ interface BudgetItem {
+ id: string;
+ name: string;
+ mainCategory: string;
+ subcategory: string;
+ source: string;
+ monthlyValues: Record;
+ values: {
+ year1: { best: number; worst: number; mostLikely: number };
+ year2: { best: number; worst: number; mostLikely: number };
+ year3: { best: number; worst: number; mostLikely: number };
+ };
+ }
+
// NEW: Budget worksheet structure (starts empty, populated from wizard data)
- const budgetWorksheet = ref({
+ const budgetWorksheet = ref<{
+ revenue: BudgetItem[];
+ expenses: BudgetItem[];
+ }>({
revenue: [],
expenses: [],
});
@@ -271,6 +290,30 @@ export const useBudgetStore = defineStore(
currentPeriod.value = period;
}
+ // Helper function to map stream category to budget category
+ function mapStreamToBudgetCategory(streamCategory) {
+ const categoryLower = (streamCategory || "").toLowerCase();
+
+ // More comprehensive category mapping
+ if (categoryLower.includes("game") || categoryLower.includes("product")) {
+ return "Games & Products";
+ } else if (categoryLower.includes("service") || categoryLower.includes("consulting")) {
+ return "Services & Contracts";
+ } else if (categoryLower.includes("grant") || categoryLower.includes("funding")) {
+ return "Grants & Funding";
+ } else if (categoryLower.includes("community") || categoryLower.includes("donation")) {
+ return "Community Support";
+ } else if (categoryLower.includes("partnership")) {
+ return "Partnerships";
+ } else if (categoryLower.includes("investment")) {
+ return "Investment Income";
+ } else if (categoryLower.includes("other")) {
+ return "Services & Contracts"; // Map "Other" to services as fallback
+ } else {
+ return "Games & Products"; // Default fallback
+ }
+ }
+
// Initialize worksheet from wizard data
async function initializeFromWizardData() {
if (isInitialized.value && budgetWorksheet.value.revenue.length > 0) {
@@ -280,340 +323,232 @@ export const useBudgetStore = defineStore(
console.log("Initializing budget from wizard data...");
- // Import stores dynamically to avoid circular deps
- const { useStreamsStore } = await import("./streams");
- const { useMembersStore } = await import("./members");
- const { usePoliciesStore } = await import("./policies");
+ try {
+ // Use the new coopBuilder store instead of the old stores
+ const coopStore = useCoopBuilderStore();
- const streamsStore = useStreamsStore();
- const membersStore = useMembersStore();
- const policiesStore = usePoliciesStore();
+ console.log("Streams:", coopStore.streams.length, "streams");
+ console.log("Members:", coopStore.members.length, "members");
+ console.log("Equal wage:", coopStore.equalHourlyWage || "No wage set");
+ console.log("Overhead costs:", coopStore.overheadCosts.length, "costs");
- console.log("Streams:", streamsStore.streams.length, "streams");
- console.log("Members capacity:", membersStore.capacityTotals);
- console.log("Policies wage:", policiesStore.equalHourlyWage);
+ // Clear existing data
+ budgetWorksheet.value.revenue = [];
+ budgetWorksheet.value.expenses = [];
- // Clear existing data
- budgetWorksheet.value.revenue = [];
- budgetWorksheet.value.expenses = [];
+ // Add revenue streams from wizard (but don't auto-load fixtures)
+ // Note: We don't auto-load fixtures anymore, but wizard data should still work
- // Add revenue streams from wizard
- if (streamsStore.streams.length === 0) {
- console.log("No wizard streams found, adding sample data");
- // Initialize with minimal demo if no wizard data exists
- await streamsStore.initializeWithFixtures();
- }
+ coopStore.streams.forEach((stream) => {
+ const monthlyAmount = stream.monthly || 0;
+ console.log(
+ "Adding stream:",
+ stream.label,
+ "category:",
+ stream.category,
+ "amount:",
+ monthlyAmount
+ );
- streamsStore.streams.forEach((stream) => {
- const monthlyAmount = stream.targetMonthlyAmount || 0;
- console.log(
- "Adding stream:",
- stream.name,
- "category:",
- stream.category,
- "subcategory:",
- stream.subcategory,
- "amount:",
- monthlyAmount
- );
- console.log("Full stream object:", stream);
+ // Use the helper function for category mapping
+ const mappedCategory = mapStreamToBudgetCategory(stream.category);
- // Simple category mapping - just map the key categories we know exist
- let mappedCategory = "Games & Products"; // Default
- const categoryLower = (stream.category || "").toLowerCase();
- if (categoryLower === "games" || categoryLower === "product")
- mappedCategory = "Games & Products";
- else if (categoryLower === "services" || categoryLower === "service")
- mappedCategory = "Services & Contracts";
- else if (categoryLower === "grants" || categoryLower === "grant")
- mappedCategory = "Grants & Funding";
- else if (categoryLower === "community")
- mappedCategory = "Community Support";
- else if (
- categoryLower === "partnerships" ||
- categoryLower === "partnership"
- )
- mappedCategory = "Partnerships";
- else if (categoryLower === "investment")
- mappedCategory = "Investment Income";
+ console.log(
+ "Mapped category from",
+ stream.category,
+ "to",
+ mappedCategory
+ );
- console.log(
- "Mapped category from",
- stream.category,
- "to",
- mappedCategory
- );
-
- // Create monthly values - split the annual target evenly across 12 months
- const monthlyValues = {};
- const today = new Date();
- for (let i = 0; i < 12; i++) {
- const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
- const monthKey = `${date.getFullYear()}-${String(
- date.getMonth() + 1
- ).padStart(2, "0")}`;
- monthlyValues[monthKey] = monthlyAmount;
- }
- console.log(
- "Created monthly values for",
- stream.name,
- ":",
- monthlyValues
- );
-
- budgetWorksheet.value.revenue.push({
- id: `revenue-${stream.id}`,
- name: stream.name,
- mainCategory: mappedCategory,
- subcategory: stream.subcategory || "Direct sales", // Use actual subcategory from stream
- source: "wizard",
- monthlyValues,
- values: {
- year1: {
- best: monthlyAmount * 12,
- worst: monthlyAmount * 6,
- mostLikely: monthlyAmount * 10,
- },
- year2: {
- best: monthlyAmount * 15,
- worst: monthlyAmount * 8,
- mostLikely: monthlyAmount * 12,
- },
- year3: {
- best: monthlyAmount * 18,
- worst: monthlyAmount * 10,
- mostLikely: monthlyAmount * 15,
- },
- },
- });
- });
-
- // Add payroll from wizard data
- const totalHours = membersStore.capacityTotals.targetHours || 0;
- const hourlyWage = policiesStore.equalHourlyWage || 0;
- const oncostPct = policiesStore.payrollOncostPct || 0;
- if (totalHours > 0 && hourlyWage > 0) {
- const monthlyPayroll = totalHours * hourlyWage * (1 + oncostPct / 100);
-
- // Create monthly values for payroll
- const monthlyValues = {};
- const today = new Date();
- for (let i = 0; i < 12; i++) {
- const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
- const monthKey = `${date.getFullYear()}-${String(
- date.getMonth() + 1
- ).padStart(2, "0")}`;
- monthlyValues[monthKey] = monthlyPayroll;
- }
-
- budgetWorksheet.value.expenses.push({
- id: "expense-payroll",
- name: "Payroll",
- mainCategory: "Salaries & Benefits",
- subcategory: "Base wages and benefits",
- source: "wizard",
- monthlyValues,
- values: {
- year1: {
- best: monthlyPayroll * 12,
- worst: monthlyPayroll * 8,
- mostLikely: monthlyPayroll * 12,
- },
- year2: {
- best: monthlyPayroll * 14,
- worst: monthlyPayroll * 10,
- mostLikely: monthlyPayroll * 13,
- },
- year3: {
- best: monthlyPayroll * 16,
- worst: monthlyPayroll * 12,
- mostLikely: monthlyPayroll * 15,
- },
- },
- });
- }
-
- // Add overhead costs from wizard
- overheadCosts.value.forEach((cost) => {
- if (cost.amount > 0) {
- const annualAmount = cost.amount * 12;
- // Map overhead cost categories to expense categories
- let expenseCategory = "Other Expenses"; // Default
- if (cost.category === "Operations")
- expenseCategory = "Office & Operations";
- else if (cost.category === "Technology")
- expenseCategory = "Equipment & Technology";
- else if (cost.category === "Legal")
- expenseCategory = "Legal & Professional";
- else if (cost.category === "Marketing")
- expenseCategory = "Marketing & Outreach";
-
- // Create monthly values for overhead costs
- const monthlyValues = {};
+ // Create monthly values - split the annual target evenly across 12 months
+ const monthlyValues: Record = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
- monthlyValues[monthKey] = cost.amount;
+ monthlyValues[monthKey] = monthlyAmount;
}
+ console.log(
+ "Created monthly values for",
+ stream.label,
+ ":",
+ monthlyValues
+ );
- budgetWorksheet.value.expenses.push({
- id: `expense-${cost.id}`,
- name: cost.name,
- mainCategory: expenseCategory,
- subcategory: cost.name, // Use the cost name as subcategory
+ budgetWorksheet.value.revenue.push({
+ id: `revenue-${stream.id}`,
+ name: stream.label,
+ mainCategory: mappedCategory,
+ subcategory: "Direct sales", // Default subcategory for coopStore streams
source: "wizard",
monthlyValues,
values: {
year1: {
- best: annualAmount,
- worst: annualAmount * 0.8,
- mostLikely: annualAmount,
+ best: monthlyAmount * 12,
+ worst: monthlyAmount * 6,
+ mostLikely: monthlyAmount * 10,
},
year2: {
- best: annualAmount * 1.1,
- worst: annualAmount * 0.9,
- mostLikely: annualAmount * 1.05,
+ best: monthlyAmount * 15,
+ worst: monthlyAmount * 8,
+ mostLikely: monthlyAmount * 12,
},
year3: {
- best: annualAmount * 1.2,
- worst: annualAmount,
- mostLikely: annualAmount * 1.1,
+ best: monthlyAmount * 18,
+ worst: monthlyAmount * 10,
+ mostLikely: monthlyAmount * 15,
},
},
});
- }
- });
+ });
- // Add production costs from wizard
- productionCosts.value.forEach((cost) => {
- if (cost.amount > 0) {
- const annualAmount = cost.amount * 12;
- // Create monthly values for production costs
- const monthlyValues = {};
+ // Add payroll from wizard data using the allocatePayroll function
+ const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || 0), 0);
+ const hourlyWage = coopStore.equalHourlyWage || 0;
+ const oncostPct = coopStore.payrollOncostPct || 0;
+
+ // Calculate total payroll budget (before oncosts)
+ const basePayrollBudget = totalHours * hourlyWage;
+
+ if (basePayrollBudget > 0 && coopStore.members.length > 0) {
+ // Calculate total with oncosts
+ const monthlyPayroll = basePayrollBudget * (1 + oncostPct / 100);
+
+ // Create monthly values for payroll
+ const monthlyValues: Record = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
- monthlyValues[monthKey] = cost.amount;
+ monthlyValues[monthKey] = monthlyPayroll;
}
budgetWorksheet.value.expenses.push({
- id: `expense-${cost.id}`,
- name: cost.name,
- mainCategory: "Development Costs",
- subcategory: cost.name, // Use the cost name as subcategory
+ id: "expense-payroll",
+ name: "Payroll",
+ mainCategory: "Salaries & Benefits",
+ subcategory: "Base wages and benefits",
source: "wizard",
monthlyValues,
values: {
year1: {
- best: annualAmount,
- worst: annualAmount * 0.7,
- mostLikely: annualAmount * 0.9,
+ best: monthlyPayroll * 12,
+ worst: monthlyPayroll * 8,
+ mostLikely: monthlyPayroll * 12,
},
year2: {
- best: annualAmount * 1.2,
- worst: annualAmount * 0.8,
- mostLikely: annualAmount,
+ best: monthlyPayroll * 14,
+ worst: monthlyPayroll * 10,
+ mostLikely: monthlyPayroll * 13,
},
year3: {
- best: annualAmount * 1.3,
- worst: annualAmount * 0.9,
- mostLikely: annualAmount * 1.1,
+ best: monthlyPayroll * 16,
+ worst: monthlyPayroll * 12,
+ mostLikely: monthlyPayroll * 15,
},
},
});
}
- });
- // If still no data after initialization, add a sample row
- if (budgetWorksheet.value.revenue.length === 0) {
- console.log("Adding sample revenue line");
- // Create monthly values for sample revenue
- const monthlyValues = {};
- const today = new Date();
- for (let i = 0; i < 12; i++) {
- const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
- const monthKey = `${date.getFullYear()}-${String(
- date.getMonth() + 1
- ).padStart(2, "0")}`;
- monthlyValues[monthKey] = 667; // ~8000/12
- }
+ // Add overhead costs from wizard
+ coopStore.overheadCosts.forEach((cost) => {
+ if (cost.amount && cost.amount > 0) {
+ const annualAmount = cost.amount * 12;
+ // Map overhead cost categories to expense categories
+ let expenseCategory = "Other Expenses"; // Default
+ if (cost.category === "Operations")
+ expenseCategory = "Office & Operations";
+ else if (cost.category === "Tools")
+ expenseCategory = "Equipment & Technology";
+ else if (cost.category === "Professional")
+ expenseCategory = "Legal & Professional";
+ else if (cost.category === "Marketing")
+ expenseCategory = "Marketing & Outreach";
- budgetWorksheet.value.revenue.push({
- id: "revenue-sample",
- name: "Sample Revenue",
- mainCategory: "Games & Products",
- subcategory: "Direct sales",
- source: "user",
- monthlyValues,
- values: {
- year1: { best: 10000, worst: 5000, mostLikely: 8000 },
- year2: { best: 12000, worst: 6000, mostLikely: 10000 },
- year3: { best: 15000, worst: 8000, mostLikely: 12000 },
- },
+ // Create monthly values for overhead costs
+ const monthlyValues: Record = {};
+ const today = new Date();
+ for (let i = 0; i < 12; i++) {
+ const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
+ const monthKey = `${date.getFullYear()}-${String(
+ date.getMonth() + 1
+ ).padStart(2, "0")}`;
+ monthlyValues[monthKey] = cost.amount;
+ }
+
+ budgetWorksheet.value.expenses.push({
+ id: `expense-${cost.id}`,
+ name: cost.name,
+ mainCategory: expenseCategory,
+ subcategory: cost.name, // Use the cost name as subcategory
+ source: "wizard",
+ monthlyValues,
+ values: {
+ year1: {
+ best: annualAmount,
+ worst: annualAmount * 0.8,
+ mostLikely: annualAmount,
+ },
+ year2: {
+ best: annualAmount * 1.1,
+ worst: annualAmount * 0.9,
+ mostLikely: annualAmount * 1.05,
+ },
+ year3: {
+ best: annualAmount * 1.2,
+ worst: annualAmount,
+ mostLikely: annualAmount * 1.1,
+ },
+ },
+ });
+ }
});
- }
- if (budgetWorksheet.value.expenses.length === 0) {
- console.log("Adding sample expense line");
- // Create monthly values for sample expense
- const monthlyValues = {};
- const today = new Date();
- for (let i = 0; i < 12; i++) {
- const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
- const monthKey = `${date.getFullYear()}-${String(
- date.getMonth() + 1
- ).padStart(2, "0")}`;
- monthlyValues[monthKey] = 67; // ~800/12
- }
+ // Production costs are handled within overhead costs in the new architecture
- budgetWorksheet.value.expenses.push({
- id: "expense-sample",
- name: "Sample Expense",
- mainCategory: "Other Expenses",
- subcategory: "Miscellaneous",
- source: "user",
- monthlyValues,
- values: {
- year1: { best: 1000, worst: 500, mostLikely: 800 },
- year2: { best: 1200, worst: 600, mostLikely: 1000 },
- year3: { best: 1500, worst: 800, mostLikely: 1200 },
- },
+ // DISABLED: No sample data - budget should start empty
+ // if (budgetWorksheet.value.revenue.length === 0) {
+ // console.log("Adding sample revenue line");
+ // // ... sample revenue creation code removed
+ // }
+
+ // DISABLED: No sample data - expenses should start empty
+ // if (budgetWorksheet.value.expenses.length === 0) {
+ // console.log("Adding sample expense line");
+ // // ... sample expense creation code removed
+ // }
+
+ // Debug: Log all revenue items and their categories
+ console.log("Final revenue items:");
+ budgetWorksheet.value.revenue.forEach((item) => {
+ console.log(
+ `- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})`
+ );
});
- }
- // Debug: Log all revenue items and their categories
- console.log("Final revenue items:");
- budgetWorksheet.value.revenue.forEach((item) => {
- console.log(
- `- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})`
- );
- });
+ console.log("Final expense items:");
+ budgetWorksheet.value.expenses.forEach((item) => {
+ console.log(
+ `- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})`
+ );
+ });
- console.log("Final expense items:");
- budgetWorksheet.value.expenses.forEach((item) => {
- console.log(
- `- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})`
- );
- });
+ // Ensure all items have monthlyValues and new structure - migrate existing items
+ [
+ ...budgetWorksheet.value.revenue,
+ ...budgetWorksheet.value.expenses,
+ ].forEach((item) => {
+ // Migrate to new structure if needed
+ if (item.category && !item.mainCategory) {
+ console.log("Migrating item structure for:", item.name);
+ item.mainCategory = item.category; // Old category becomes mainCategory
- // Ensure all items have monthlyValues and new structure - migrate existing items
- [
- ...budgetWorksheet.value.revenue,
- ...budgetWorksheet.value.expenses,
- ].forEach((item) => {
- // Migrate to new structure if needed
- if (item.category && !item.mainCategory) {
- console.log("Migrating item structure for:", item.name);
- item.mainCategory = item.category; // Old category becomes mainCategory
-
- // Set appropriate subcategory based on the main category and item name
- if (item.category === "Games & Products") {
+ // Set appropriate subcategory based on the main category and item name
+ if (item.category === "Games & Products") {
const gameSubcategories = [
"Direct sales",
"Platform revenue share",
@@ -688,14 +623,22 @@ export const useBudgetStore = defineStore(
}
});
- console.log(
- "Initialization complete. Revenue items:",
- budgetWorksheet.value.revenue.length,
- "Expense items:",
- budgetWorksheet.value.expenses.length
- );
+ console.log(
+ "Initialization complete. Revenue items:",
+ budgetWorksheet.value.revenue.length,
+ "Expense items:",
+ budgetWorksheet.value.expenses.length
+ );
- isInitialized.value = true;
+ isInitialized.value = true;
+ } catch (error) {
+ console.error("Error initializing budget from wizard data:", error);
+
+ // DISABLED: No fallback sample data - budget should remain empty on errors
+ // Budget initialization complete (without automatic fallback data)
+
+ isInitialized.value = true;
+ }
}
// NEW: Budget worksheet functions
diff --git a/stores/cash.ts b/stores/cash.ts
index dfe8263..a0d9c7f 100644
--- a/stores/cash.ts
+++ b/stores/cash.ts
@@ -10,7 +10,7 @@ export const useCashStore = defineStore("cash", () => {
// Week that first breaches minimum cushion
const firstBreachWeek = ref(null);
- // Current cash and savings balances - start with zeros
+ // Current cash and savings balances - start empty
const currentCash = ref(0);
const currentSavings = ref(0);
@@ -111,4 +111,14 @@ export const useCashStore = defineStore("cash", () => {
stagePayment,
updateCurrentBalances,
};
+}, {
+ persist: {
+ key: "urgent-tools-cash",
+ paths: [
+ "currentCash",
+ "currentSavings",
+ "cashEvents",
+ "paymentQueue"
+ ],
+ },
});
diff --git a/stores/coop-builder.ts b/stores/coop-builder.ts
deleted file mode 100644
index 104fd88..0000000
--- a/stores/coop-builder.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { defineStore } from "pinia";
-
-export const useCoopBuilderStore = defineStore(
- "coop-builder",
- () => {
- const currentStep = ref(1);
-
- function setStep(step: number) {
- currentStep.value = Math.min(Math.max(1, step), 5);
- }
-
- function reset() {
- currentStep.value = 1;
- }
-
- return {
- currentStep,
- setStep,
- reset,
- };
- },
- {
- persist: {
- key: "urgent-tools-wizard",
- paths: ["currentStep"],
- },
- }
-);
diff --git a/stores/coopBuilder.ts b/stores/coopBuilder.ts
new file mode 100644
index 0000000..ab75a80
--- /dev/null
+++ b/stores/coopBuilder.ts
@@ -0,0 +1,277 @@
+import { defineStore } from "pinia";
+
+export const useCoopBuilderStore = defineStore("coop", {
+ state: () => ({
+ operatingMode: "min" as "min" | "target",
+
+ // Flag to track if data was intentionally cleared
+ _wasCleared: false,
+
+ members: [] as Array<{
+ id: string;
+ name: string;
+ role?: string;
+ hoursPerMonth?: number;
+ minMonthlyNeeds: number;
+ targetMonthlyPay: number;
+ externalMonthlyIncome: number;
+ monthlyPayPlanned: number;
+ }>,
+
+ streams: [] as Array<{
+ id: string;
+ label: string;
+ monthly: number;
+ category?: string;
+ certainty?: string;
+ }>,
+
+ milestones: [] as Array<{
+ id: string;
+ label: string;
+ date: string;
+ }>,
+
+ // Scenario and stress test state
+ scenario: "current" as
+ | "current"
+ | "quit-jobs"
+ | "start-production"
+ | "custom",
+ stress: {
+ revenueDelay: 0,
+ costShockPct: 0,
+ grantLost: false,
+ },
+
+ // Policy settings
+ policy: {
+ relationship: "equal-pay" as "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded",
+ roleBands: {} as Record,
+ },
+ equalHourlyWage: 50,
+ payrollOncostPct: 25,
+ savingsTargetMonths: 6,
+ minCashCushion: 10000,
+
+ // Cash reserves
+ currentCash: 50000,
+ currentSavings: 15000,
+
+ // Overhead costs
+ overheadCosts: [] as Array<{
+ id?: string;
+ name: string;
+ amount: number;
+ category?: string;
+ }>,
+ }),
+
+ getters: {
+ totalRevenue: (state) => {
+ return state.streams.reduce((sum, s) => sum + (s.monthly || 0), 0);
+ },
+
+ totalOverhead: (state) => {
+ return state.overheadCosts.reduce((sum, c) => sum + (c.amount || 0), 0);
+ },
+
+ totalLiquid: (state) => {
+ return state.currentCash + state.currentSavings;
+ },
+ },
+
+ actions: {
+ // Member actions
+ upsertMember(m: any) {
+ const i = this.members.findIndex((x) => x.id === m.id);
+ // Ensure all keys exist (prevents undefined stripping)
+ const withDefaults = {
+ id: m.id || Date.now().toString(),
+ name: m.name || m.displayName || "",
+ role: m.role ?? "",
+ hoursPerMonth: m.hoursPerMonth ?? 0,
+ minMonthlyNeeds: m.minMonthlyNeeds ?? 0,
+ targetMonthlyPay: m.targetMonthlyPay ?? 0,
+ externalMonthlyIncome: m.externalMonthlyIncome ?? 0,
+ monthlyPayPlanned: m.monthlyPayPlanned ?? 0,
+ };
+ if (i === -1) {
+ this.members.push(withDefaults);
+ } else {
+ this.members[i] = withDefaults;
+ }
+ },
+
+ removeMember(id: string) {
+ this.members = this.members.filter((m) => m.id !== id);
+ },
+
+ // Stream actions
+ upsertStream(s: any) {
+ const i = this.streams.findIndex((x) => x.id === s.id);
+ const withDefaults = {
+ id: s.id || Date.now().toString(),
+ label: s.label || s.name || "",
+ monthly: s.monthly || s.targetMonthlyAmount || 0,
+ category: s.category ?? "",
+ certainty: s.certainty ?? "Probable",
+ };
+ if (i === -1) {
+ this.streams.push(withDefaults);
+ } else {
+ this.streams[i] = withDefaults;
+ }
+ },
+
+ removeStream(id: string) {
+ this.streams = this.streams.filter((s) => s.id !== id);
+ },
+
+ // Milestone actions
+ addMilestone(label: string, date: string) {
+ this.milestones.push({
+ id: Date.now().toString(),
+ label,
+ date,
+ });
+ },
+
+ removeMilestone(id: string) {
+ this.milestones = this.milestones.filter((m) => m.id !== id);
+ },
+
+ // Operating mode
+ setOperatingMode(mode: "min" | "target") {
+ this.operatingMode = mode;
+ },
+
+ // Scenario
+ setScenario(
+ scenario: "current" | "quit-jobs" | "start-production" | "custom"
+ ) {
+ this.scenario = scenario;
+ },
+
+ // Stress test
+ updateStress(updates: Partial) {
+ this.stress = { ...this.stress, ...updates };
+ },
+
+ // Policy updates
+ setPolicy(relationship: "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded") {
+ this.policy.relationship = relationship;
+ },
+
+ setRoleBands(bands: Record) {
+ this.policy.roleBands = bands;
+ },
+
+ setEqualWage(wage: number) {
+ this.equalHourlyWage = wage;
+ },
+
+ setOncostPct(pct: number) {
+ this.payrollOncostPct = pct;
+ },
+
+ // Overhead costs
+ addOverheadCost(cost: any) {
+ const withDefaults = {
+ id: cost.id || Date.now().toString(),
+ name: cost.name || "",
+ amount: cost.amount || 0,
+ category: cost.category ?? "",
+ };
+ this.overheadCosts.push(withDefaults);
+ },
+
+ upsertOverheadCost(cost: any) {
+ const i = this.overheadCosts.findIndex((c) => c.id === cost.id);
+ const withDefaults = {
+ id: cost.id || Date.now().toString(),
+ name: cost.name || "",
+ amount: cost.amount || 0,
+ category: cost.category ?? "",
+ };
+ if (i === -1) {
+ this.overheadCosts.push(withDefaults);
+ } else {
+ this.overheadCosts[i] = withDefaults;
+ }
+ },
+
+ removeOverheadCost(id: string) {
+ this.overheadCosts = this.overheadCosts.filter((c) => c.id !== id);
+ },
+
+ // Initialize with default data if empty - DISABLED
+ // NO automatic initialization - stores should start empty
+ initializeDefaults() {
+ // DISABLED: No automatic data loading
+ // User must explicitly choose to load demo data
+ return;
+ },
+
+ // Clear ALL data - no exceptions
+ clearAll() {
+ // Reset ALL state to initial empty values
+ this._wasCleared = true;
+ this.operatingMode = "min";
+ this.members = [];
+ this.streams = [];
+ this.milestones = [];
+ this.scenario = "current";
+ this.stress = {
+ revenueDelay: 0,
+ costShockPct: 0,
+ grantLost: false,
+ };
+ this.policy = {
+ relationship: "equal-pay",
+ roleBands: {},
+ };
+ this.equalHourlyWage = 0;
+ this.payrollOncostPct = 0;
+ this.savingsTargetMonths = 0;
+ this.minCashCushion = 0;
+ this.currentCash = 0;
+ this.currentSavings = 0;
+ this.overheadCosts = [];
+
+ // Clear ALL localStorage data
+ if (typeof window !== "undefined") {
+ // Save cleared flag first
+ localStorage.setItem("urgent-tools-cleared-flag", "true");
+
+ // Remove all known keys
+ const keysToRemove = [
+ "coop_builder_v1",
+ "urgent-tools-members",
+ "urgent-tools-policies",
+ "urgent-tools-streams",
+ "urgent-tools-budget",
+ "urgent-tools-cash",
+ "urgent-tools-schema-version",
+ ];
+
+ keysToRemove.forEach((key) => localStorage.removeItem(key));
+
+ // Clear any other urgent-tools or coop keys
+ const allKeys = Object.keys(localStorage);
+ allKeys.forEach((key) => {
+ if (key.startsWith("urgent-tools-") || key.startsWith("coop_")) {
+ if (key !== "urgent-tools-cleared-flag") {
+ localStorage.removeItem(key);
+ }
+ }
+ });
+ }
+ },
+ },
+
+ persist: {
+ key: "coop_builder_v1",
+ storage: typeof window !== "undefined" ? localStorage : undefined,
+ },
+});
diff --git a/stores/members.ts b/stores/members.ts
index 891d647..df49835 100644
--- a/stores/members.ts
+++ b/stores/members.ts
@@ -1,4 +1,6 @@
import { defineStore } from "pinia";
+import { ref, computed } from 'vue';
+import { coverage, teamCoverageStats } from "~/types/members";
export const useMembersStore = defineStore(
"members",
@@ -34,10 +36,16 @@ export const useMembersStore = defineStore(
// Normalize a member object to ensure required structure and sane defaults
function normalizeMember(raw) {
+ // Calculate hoursPerWeek from targetHours (monthly) if not explicitly set
+ const targetHours = Number(raw.capacity?.targetHours) || 0;
+ const hoursPerWeek = raw.hoursPerWeek ?? (targetHours > 0 ? targetHours / 4.33 : 0);
+
const normalized = {
id: raw.id || Date.now().toString(),
displayName: typeof raw.displayName === "string" ? raw.displayName : "",
roleFocus: typeof raw.roleFocus === "string" ? raw.roleFocus : "",
+ role: raw.role || raw.roleFocus || "",
+ hoursPerWeek: hoursPerWeek,
payRelationship: raw.payRelationship || "FullyPaid",
capacity: {
minHours: Number(raw.capacity?.minHours) || 0,
@@ -49,6 +57,11 @@ export const useMembersStore = defineStore(
privacyNeeds: raw.privacyNeeds || "aggregate_ok",
deferredHours: Number(raw.deferredHours ?? 0),
quarterlyDeferredCap: Number(raw.quarterlyDeferredCap ?? 240),
+ // NEW fields for needs coverage
+ minMonthlyNeeds: Number(raw.minMonthlyNeeds) || 0,
+ targetMonthlyPay: Number(raw.targetMonthlyPay) || 0,
+ externalMonthlyIncome: Number(raw.externalMonthlyIncome) || 0,
+ monthlyPayPlanned: Number(raw.monthlyPayPlanned) || 0,
...raw,
};
return normalized;
@@ -187,6 +200,56 @@ export const useMembersStore = defineStore(
members.value = [];
}
+ // Coverage calculations for individual members
+ function getMemberCoverage(memberId) {
+ const member = members.value.find((m) => m.id === memberId);
+ if (!member) return { minPct: undefined, targetPct: undefined };
+
+ return coverage(
+ member.minMonthlyNeeds || 0,
+ member.targetMonthlyPay || 0,
+ member.monthlyPayPlanned || 0,
+ member.externalMonthlyIncome || 0
+ );
+ }
+
+ // Team-wide coverage statistics
+ const teamStats = computed(() => teamCoverageStats(members.value));
+
+ // Pay policy configuration
+ const payPolicy = ref({
+ relationship: 'equal-pay' as const,
+ notes: '',
+ equalBase: 0,
+ needsWeight: 0.5,
+ roleBands: {},
+ hoursRate: 0,
+ customFormula: ''
+ });
+
+ // Setters for new fields
+ function setMonthlyNeeds(memberId, minNeeds, targetPay) {
+ const member = members.value.find((m) => m.id === memberId);
+ if (member) {
+ member.minMonthlyNeeds = Number(minNeeds) || 0;
+ member.targetMonthlyPay = Number(targetPay) || 0;
+ }
+ }
+
+ function setExternalIncome(memberId, income) {
+ const member = members.value.find((m) => m.id === memberId);
+ if (member) {
+ member.externalMonthlyIncome = Number(income) || 0;
+ }
+ }
+
+ function setPlannedPay(memberId, planned) {
+ const member = members.value.find((m) => m.id === memberId);
+ if (member) {
+ member.monthlyPayPlanned = Number(planned) || 0;
+ }
+ }
+
return {
members,
capacityTotals,
@@ -194,6 +257,8 @@ export const useMembersStore = defineStore(
validationDetails,
isValid,
schemaVersion,
+ payPolicy,
+ teamStats,
// Wizard actions
upsertMember,
setCapacity,
@@ -202,6 +267,11 @@ export const useMembersStore = defineStore(
setExternalCoveragePct,
setPrivacy,
resetMembers,
+ // New coverage actions
+ setMonthlyNeeds,
+ setExternalIncome,
+ setPlannedPay,
+ getMemberCoverage,
// Legacy actions
addMember,
updateMember,
@@ -211,7 +281,7 @@ export const useMembersStore = defineStore(
{
persist: {
key: "urgent-tools-members",
- paths: ["members", "privacyFlags"],
+ paths: ["members", "privacyFlags", "payPolicy"],
},
}
);
diff --git a/stores/policies.ts b/stores/policies.ts
index 1c0e477..e77c692 100644
--- a/stores/policies.ts
+++ b/stores/policies.ts
@@ -11,6 +11,13 @@ export const usePoliciesStore = defineStore(
const payrollOncostPct = ref(0);
const savingsTargetMonths = ref(0);
const minCashCushionAmount = ref(0);
+ const operatingMode = ref<'minimum' | 'target'>('minimum');
+
+ // Pay policy for member needs coverage
+ const payPolicy = ref({
+ relationship: 'equal-pay' as 'equal-pay' | 'needs-weighted' | 'role-banded' | 'hours-weighted',
+ roleBands: [] as Array<{ role: string; hourlyWage: number }>
+ });
// Deferred pay limits
const deferredCapHoursPerQtr = ref(0);
@@ -95,6 +102,13 @@ export const usePoliciesStore = defineStore(
function setPaymentPriority(priority) {
paymentPriority.value = [...priority];
}
+
+ function setPayPolicy(relationship, roleBands = []) {
+ payPolicy.value = {
+ relationship,
+ roleBands: [...roleBands]
+ };
+ }
// Legacy actions
function updatePolicy(key, value) {
@@ -120,6 +134,8 @@ export const usePoliciesStore = defineStore(
payrollOncostPct.value = 0;
savingsTargetMonths.value = 0;
minCashCushionAmount.value = 0;
+ operatingMode.value = 'minimum';
+ payPolicy.value = { relationship: 'equal-pay', roleBands: [] };
deferredCapHoursPerQtr.value = 0;
deferredSunsetMonths.value = 0;
surplusOrder.value = [
@@ -139,6 +155,8 @@ export const usePoliciesStore = defineStore(
payrollOncostPct,
savingsTargetMonths,
minCashCushionAmount,
+ operatingMode,
+ payPolicy,
deferredCapHoursPerQtr,
deferredSunsetMonths,
surplusOrder,
@@ -151,6 +169,7 @@ export const usePoliciesStore = defineStore(
setOncostPct,
setSavingsTargetMonths,
setMinCashCushion,
+ setPayPolicy,
setDeferredCap,
setDeferredSunset,
setVolunteerScope,
@@ -171,6 +190,8 @@ export const usePoliciesStore = defineStore(
"payrollOncostPct",
"savingsTargetMonths",
"minCashCushionAmount",
+ "operatingMode",
+ "payPolicy",
"deferredCapHoursPerQtr",
"deferredSunsetMonths",
"surplusOrder",
diff --git a/stores/streams.ts b/stores/streams.ts
index e5e18f4..90ab989 100644
--- a/stores/streams.ts
+++ b/stores/streams.ts
@@ -87,30 +87,6 @@ export const useStreamsStore = defineStore(
}
}
- // Initialize with fixture data if empty
- async function initializeWithFixtures() {
- if (streams.value.length === 0) {
- const { useFixtures } = await import('~/composables/useFixtures');
- const fixtures = useFixtures();
- const { revenueStreams } = await fixtures.loadStreams();
-
- revenueStreams.forEach(stream => {
- upsertStream(stream);
- });
- }
- }
-
- // Load realistic demo data (for better user experience)
- async function loadDemoData() {
- resetStreams();
- const { useFixtures } = await import('~/composables/useFixtures');
- const fixtures = useFixtures();
- const { revenueStreams } = await fixtures.loadStreams();
-
- revenueStreams.forEach(stream => {
- upsertStream(stream);
- });
- }
// Reset function
function resetStreams() {
@@ -127,8 +103,6 @@ export const useStreamsStore = defineStore(
// Wizard actions
upsertStream,
resetStreams,
- initializeWithFixtures,
- loadDemoData,
// Legacy actions
addStream,
updateStream,
diff --git a/tailwind.config.ts b/tailwind.config.ts
index b1aeef9..115f9af 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -1,5 +1,13 @@
import type { Config } from "tailwindcss";
export default {
+ content: [
+ './app.vue',
+ './pages/**/*.vue',
+ './components/**/*.{vue,js,ts}',
+ './composables/**/*.{js,ts}',
+ './layouts/**/*.vue',
+ './plugins/**/*.{js,ts}',
+ ],
darkMode: "class",
} satisfies Config;
diff --git a/tests/coach-integration.spec.ts b/tests/coach-integration.spec.ts
index f01b96a..0d87713 100644
--- a/tests/coach-integration.spec.ts
+++ b/tests/coach-integration.spec.ts
@@ -9,12 +9,35 @@ import WizardRevenueStep from '~/components/WizardRevenueStep.vue';
import { useOfferSuggestor } from '~/composables/useOfferSuggestor';
import { usePlanStore } from '~/stores/plan';
import { offerToStream, offersToStreams } from '~/utils/offerToStream';
-import {
- membersSample,
- skillsCatalogSample,
- problemsCatalogSample,
- sampleSelections
-} from '~/sample/skillsToOffersSamples';
+
+// Create inline test data to replace removed sample imports
+const membersSample = [
+ { id: "1", name: "Maya Chen", role: "Designer", hourly: 32, availableHrs: 20 },
+ { id: "2", name: "Alex Rodriguez", role: "Developer", hourly: 35, availableHrs: 30 },
+ { id: "3", name: "Jordan Kim", role: "Writer", hourly: 28, availableHrs: 15 }
+];
+
+const skillsCatalogSample = [
+ { id: "design", label: "UI/UX Design" },
+ { id: "writing", label: "Technical Writing" },
+ { id: "development", label: "Web Development" }
+];
+
+const problemsCatalogSample = [
+ {
+ id: "unclear-pitch",
+ label: "Unclear value proposition",
+ examples: ["Need better messaging", "Confusing product pitch"]
+ }
+];
+
+const sampleSelections = {
+ selectedSkillsByMember: {
+ "1": ["design"],
+ "3": ["writing"]
+ },
+ selectedProblems: ["unclear-pitch"]
+};
// Mock router
vi.mock('vue-router', () => ({
@@ -159,65 +182,34 @@ describe('Coach Integration Tests', () => {
});
describe('Coach Page Integration', () => {
- it('loads sample data and generates offers automatically', async () => {
+ it('starts with empty data by default', async () => {
const wrapper = mount(CoachSkillsToOffers, {
global: {
plugins: [pinia]
}
});
- // Trigger sample data loading
- await wrapper.vm.loadSampleData();
- await nextTick();
-
- // Wait for debounced offer generation
- await new Promise(resolve => setTimeout(resolve, 350));
-
- // Should have loaded sample members
- expect(wrapper.vm.members).toEqual(membersSample);
-
- // Should have pre-selected skills and problems
- expect(wrapper.vm.selectedSkills).toEqual(sampleSelections.selectedSkillsByMember);
- expect(wrapper.vm.selectedProblems).toEqual(sampleSelections.selectedProblems);
-
- // Should have generated offers
- expect(wrapper.vm.offers).toBeDefined();
- expect(wrapper.vm.offers?.length).toBeGreaterThan(0);
+ // Should start with empty data
+ expect(wrapper.vm.members).toEqual([]);
+ expect(wrapper.vm.availableSkills).toEqual([]);
+ expect(wrapper.vm.availableProblems).toEqual([]);
+ expect(wrapper.vm.offers).toBeNull();
});
- it('handles "Use these" action correctly', async () => {
+ it('handles empty state gracefully with no offers generated', async () => {
const wrapper = mount(CoachSkillsToOffers, {
global: {
plugins: [pinia]
}
});
- // Load sample data and generate offers
- await wrapper.vm.loadSampleData();
+ // Wait for any potential async operations
await nextTick();
- await new Promise(resolve => setTimeout(resolve, 350));
-
- // Ensure we have offers
- expect(wrapper.vm.offers?.length).toBeGreaterThan(0);
+ await new Promise(resolve => setTimeout(resolve, 100));
- const initialOffers = wrapper.vm.offers!;
-
- // Trigger "Use these" action
- await wrapper.vm.useOffers();
-
- // Should have added streams to plan store
- expect(planStore.streams.length).toBe(initialOffers.length);
-
- // Verify streams are properly converted
- planStore.streams.forEach((stream: any, index: number) => {
- const originalOffer = initialOffers[index];
- expect(stream.id).toBe(`offer-${originalOffer.id}`);
- expect(stream.name).toBe(originalOffer.name);
- expect(stream.unitPrice).toBe(originalOffer.price.baseline);
- expect(stream.payoutDelayDays).toBe(originalOffer.payoutDelayDays);
- expect(stream.feePercent).toBe(3);
- expect(stream.notes).toBe(originalOffer.whyThis.join('. '));
- });
+ // Should have no offers with empty data
+ expect(wrapper.vm.offers).toBeNull();
+ expect(wrapper.vm.canRegenerate).toBe(false);
});
});
diff --git a/types/members.ts b/types/members.ts
new file mode 100644
index 0000000..60dbb20
--- /dev/null
+++ b/types/members.ts
@@ -0,0 +1,137 @@
+export type PayRelationship =
+ | 'equal-pay'
+ | 'needs-weighted'
+ | 'role-banded'
+ | 'hours-weighted'
+ | 'custom-formula';
+
+export interface Member {
+ id: string
+ displayName: string
+ roleFocus?: string
+ role?: string
+ hoursPerWeek?: number
+ hoursPerMonth?: number
+ capacity?: {
+ minHours?: number
+ targetHours?: number
+ maxHours?: number
+ }
+
+ // Existing/planned
+ monthlyPayPlanned?: number
+
+ // NEW - early-stage friendly, defaults-safe
+ minMonthlyNeeds?: number
+ targetMonthlyPay?: number
+ externalMonthlyIncome?: number
+
+ // Compatibility with existing store
+ payRelationship?: string
+ riskBand?: string
+ externalCoveragePct?: number
+ privacyNeeds?: string
+ deferredHours?: number
+ quarterlyDeferredCap?: number
+
+ // UI-only derivations
+ coverageMinPct?: number
+ coverageTargetPct?: number
+}
+
+export interface PayPolicy {
+ relationship: PayRelationship
+ notes?: string
+ equalBase?: number
+ needsWeight?: number
+ roleBands?: Record
+ hoursRate?: number
+ customFormula?: string
+}
+
+// Coverage calculation helpers
+export function coverage(minNeeds = 0, target = 0, planned = 0, external = 0) {
+ const base = planned + external
+ const min = minNeeds > 0 ? Math.min(200, (base / minNeeds) * 100) : undefined
+ const tgt = target > 0 ? Math.min(200, (base / target) * 100) : undefined
+ return { minPct: min, targetPct: tgt }
+}
+
+export function teamCoverageStats(members: Member[]) {
+ const vals = members
+ .map(m => coverage(m.minMonthlyNeeds, m.targetMonthlyPay, m.monthlyPayPlanned, m.externalMonthlyIncome).minPct)
+ .filter((v): v is number => typeof v === 'number')
+
+ if (!vals.length) return { under100: 0, median: undefined, range: undefined, gini: undefined }
+
+ const sorted = [...vals].sort((a, b) => a - b)
+ const median = sorted[Math.floor(sorted.length / 2)]
+ const range = { min: sorted[0], max: sorted[sorted.length - 1] }
+
+ // quick Gini on coverage (0 = equal, 1 = unequal)
+ const mean = vals.reduce((a, b) => a + b, 0) / vals.length
+ let gini = 0
+ if (mean > 0) {
+ let diffSum = 0
+ for (let i = 0; i < vals.length; i++)
+ for (let j = 0; j < vals.length; j++)
+ diffSum += Math.abs(vals[i] - vals[j])
+ gini = diffSum / (2 * vals.length * vals.length * mean)
+ }
+
+ const under100 = vals.filter(v => v < 100).length
+
+ return { under100, median, range, gini }
+}
+
+// Payroll allocation based on policy
+export function allocatePayroll(members: Member[], policy: PayPolicy, payrollBudget: number): Member[] {
+ const result = JSON.parse(JSON.stringify(members)) // Safe deep clone
+
+ if (policy.relationship === 'equal-pay') {
+ const each = payrollBudget / result.length
+ result.forEach(m => m.monthlyPayPlanned = Math.max(0, each))
+ return result
+ }
+
+ if (policy.relationship === 'needs-weighted') {
+ const weights = result.map(m => m.minMonthlyNeeds ?? 0)
+ const sum = weights.reduce((a, b) => a + b, 0) || 1
+ result.forEach((m, i) => {
+ const w = weights[i] / sum
+ m.monthlyPayPlanned = Math.max(0, payrollBudget * w)
+ })
+ return result
+ }
+
+ if (policy.relationship === 'role-banded' && policy.roleBands) {
+ const bands = result.map(m => policy.roleBands![m.role ?? ''] ?? 0)
+ const sum = bands.reduce((a, b) => a + b, 0) || 1
+ result.forEach((m, i) => m.monthlyPayPlanned = payrollBudget * (bands[i] / sum))
+ return result
+ }
+
+ if (policy.relationship === 'hours-weighted') {
+ const hours = result.map(m => m.hoursPerMonth ?? (m.hoursPerWeek ? m.hoursPerWeek * 4 : 0) ?? (m.capacity?.targetHours ?? 0))
+ const sum = hours.reduce((a, b) => a + b, 0) || 1
+ result.forEach((m, i) => m.monthlyPayPlanned = payrollBudget * (hours[i] / sum))
+ return result
+ }
+
+ // fallback: equal
+ const each = payrollBudget / result.length
+ result.forEach(m => m.monthlyPayPlanned = Math.max(0, each))
+ return result
+}
+
+// Monthly payroll calculation for runway and cashflow
+export function monthlyPayroll(members: Member[], mode: 'minimum' | 'target' = 'minimum'): number {
+ return members.reduce((sum, m) => {
+ const planned = m.monthlyPayPlanned ?? 0
+ // In "minimum" mode cap at min needs to show a lean runway scenario
+ if (mode === 'minimum' && m.minMonthlyNeeds) {
+ return sum + Math.min(planned, m.minMonthlyNeeds)
+ }
+ return sum + planned
+ }, 0)
+}
\ No newline at end of file