app/pages/scenarios.vue

575 lines
19 KiB
Vue

<template>
<section class="py-8 space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Scenarios & Runway</h2>
<UButton
variant="outline"
color="red"
size="sm"
@click="restartWizard"
:disabled="isResetting">
<UIcon name="i-heroicons-arrow-path" class="mr-1" />
Restart Setup (Testing)
</UButton>
</div>
<!-- 6-Month Preset Card -->
<UCard class="bg-blue-50 border-blue-200">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-blue-900">
6-Month Plan Analysis
</h3>
<UBadge color="info" variant="solid">Recommended</UBadge>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="text-center">
<div class="text-3xl font-bold text-blue-600 mb-2">
{{ sixMonthScenario.runway }} months
</div>
<div class="text-sm text-neutral-600 mb-3">Extended runway</div>
<UProgress value="91" color="info" />
</div>
<div>
<h4 class="font-medium mb-3">Key Changes</h4>
<ul class="text-sm text-neutral-600 space-y-1">
<li> Diversify revenue mix</li>
<li> Build 6-month savings buffer</li>
<li> Gradual capacity scaling</li>
<li> Risk mitigation focus</li>
</ul>
</div>
<div>
<h4 class="font-medium mb-3">Feasibility Gates</h4>
<div class="space-y-2">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
<span class="text-sm">Savings target achievable</span>
</div>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
<span class="text-sm">Cash floor maintained</span>
</div>
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-exclamation-triangle"
class="text-yellow-500" />
<span class="text-sm">Requires 2 new streams</span>
</div>
</div>
</div>
</div>
</UCard>
<!-- Scenario Comparison -->
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<UCard class="border-green-200 bg-green-50">
<div class="text-center space-y-3">
<div class="flex items-center justify-between">
<h4 class="font-medium text-sm">Operate Current</h4>
<UBadge color="success" variant="solid" size="xs">Active</UBadge>
</div>
<div class="text-2xl font-bold text-orange-600">
{{ currentScenario.runway }} months
</div>
<div class="text-xs text-neutral-600">Baseline scenario</div>
<UButton size="xs" variant="ghost" @click="setScenario('current')">
<UIcon name="i-heroicons-play" class="mr-1" />
Continue
</UButton>
</div>
</UCard>
<UCard>
<div class="text-center space-y-3">
<div class="flex items-center justify-between">
<h4 class="font-medium text-sm">Quit Day Jobs</h4>
<UBadge color="error" variant="subtle" size="xs">High Risk</UBadge>
</div>
<div class="text-2xl font-bold text-red-600">
{{ quitDayJobsScenario.runway }} months
</div>
<div class="text-xs text-neutral-600">Full-time co-op work</div>
<UButton
size="xs"
variant="ghost"
@click="setScenario('quitDayJobs')">
<UIcon name="i-heroicons-briefcase" class="mr-1" />
Analyze
</UButton>
</div>
</UCard>
<UCard>
<div class="text-center space-y-3">
<div class="flex items-center justify-between">
<h4 class="font-medium text-sm">Start Production</h4>
<UBadge color="warning" variant="subtle" size="xs"
>Medium Risk</UBadge
>
</div>
<div class="text-2xl font-bold text-yellow-600">
{{ startProductionScenario.runway }} months
</div>
<div class="text-xs text-neutral-600">Launch development</div>
<UButton
size="xs"
variant="ghost"
@click="setScenario('startProduction')">
<UIcon name="i-heroicons-rocket-launch" class="mr-1" />
Analyze
</UButton>
</div>
</UCard>
<UCard class="border-blue-200">
<div class="text-center space-y-3">
<div class="flex items-center justify-between">
<h4 class="font-medium text-sm">6-Month Plan</h4>
<UBadge color="info" variant="solid" size="xs">Planned</UBadge>
</div>
<div class="text-2xl font-bold text-blue-600">
{{ sixMonthScenario.runway }} months
</div>
<div class="text-xs text-neutral-600">Extended planning</div>
<UButton size="xs" color="primary" @click="setScenario('sixMonth')">
<UIcon name="i-heroicons-calendar" class="mr-1" />
Plan
</UButton>
</div>
</UCard>
</div>
<!-- Feasibility Analysis -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Feasibility Analysis</h3>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 class="font-medium mb-3">Gate Checks</h4>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm">Savings Target Reached</span>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-x-circle" class="text-red-500" />
<span class="text-sm text-neutral-600">5,200 short</span>
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">Cash Floor Maintained</span>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
<span class="text-sm text-neutral-600">Week 4+</span>
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">Revenue Diversification</span>
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-exclamation-triangle"
class="text-yellow-500" />
<span class="text-sm text-neutral-600"
>Top: {{ topSourcePct }}%</span
>
</div>
</div>
</div>
</div>
<div>
<h4 class="font-medium mb-3">Key Dates</h4>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-sm text-neutral-600">Savings gate clear:</span>
<span class="text-sm font-medium">{{
keyDates.savingsGate || "Not projected"
}}</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-neutral-600">First cash breach:</span>
<span class="text-sm font-medium text-red-600">{{
keyDates.firstBreach || "None projected"
}}</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-neutral-600">Deferred cap reset:</span>
<span class="text-sm font-medium">{{
keyDates.deferredReset || "Not scheduled"
}}</span>
</div>
</div>
</div>
</div>
</UCard>
<!-- What-If Sliders -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">What-If Analysis</h3>
<UButton size="sm" variant="ghost" @click="resetSliders">
Reset
</UButton>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-4">
<div>
<label for="revenue-slider" class="block text-sm font-medium mb-2">
<GlossaryTooltip
term="Monthly Revenue"
term-id="revenue"
definition="Total money earned from all streams in one month." />:
{{ $format.currency(revenue) }}
</label>
<div class="flex items-center gap-3">
<URange
id="revenue-slider"
v-model="revenue"
:min="5000"
:max="20000"
:step="500"
:aria-label="`Monthly revenue: ${$format.currency(revenue)}`"
class="flex-1" />
<UInput
v-model="revenue"
type="number"
:min="5000"
:max="20000"
:step="500"
class="w-24"
size="xs"
aria-label="Monthly revenue input" />
</div>
</div>
<div>
<label for="hours-slider" class="block text-sm font-medium mb-2">
Paid Hours: {{ paidHours }}h/month
</label>
<div class="flex items-center gap-3">
<URange
id="hours-slider"
v-model="paidHours"
:min="100"
:max="600"
:step="20"
:aria-label="`Paid hours: ${paidHours} per month`"
class="flex-1" />
<UInput
v-model="paidHours"
type="number"
:min="100"
:max="600"
:step="20"
class="w-20"
size="xs"
aria-label="Paid hours input" />
</div>
</div>
<div>
<label for="winrate-slider" class="block text-sm font-medium mb-2">
Win Rate: {{ winRate }}%
</label>
<div class="flex items-center gap-3">
<URange
id="winrate-slider"
v-model="winRate"
:min="40"
:max="95"
:step="5"
:aria-label="`Win rate: ${winRate} percent`"
class="flex-1" />
<UInput
v-model="winRate"
type="number"
:min="40"
:max="95"
:step="5"
class="w-16"
size="xs"
aria-label="Win rate input" />
</div>
</div>
</div>
<div class="space-y-4">
<div class="bg-neutral-50 rounded-lg p-4">
<h4 class="font-medium text-sm mb-3">Impact on Runway</h4>
<div class="text-center">
<div
class="text-2xl font-bold"
:class="getRunwayColor(calculatedRunway)">
{{ calculatedRunway }} months
</div>
<UProgress
:value="Math.min(calculatedRunway * 10, 100)"
:max="100"
:color="getProgressColor(calculatedRunway)"
class="mt-2" />
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Monthly burn:</span>
<span class="font-medium"
>{{ monthlyBurn.toLocaleString() }}</span
>
</div>
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Coverage ratio:</span>
<span class="font-medium"
>{{ Math.round((paidHours / 400) * 100) }}%</span
>
</div>
</div>
</div>
</div>
</UCard>
</section>
</template>
<script setup lang="ts">
const { $format } = useNuxtApp();
const route = useRoute();
const router = useRouter();
const scenariosStore = useScenariosStore();
// Restart wizard functionality
const membersStore = useMembersStore();
const policiesStore = usePoliciesStore();
const streamsStore = useStreamsStore();
const budgetStore = useBudgetStore();
const cashStore = useCashStore();
const sessionStore = useSessionStore();
const coopBuilderStore = useCoopBuilderStore();
const isResetting = ref(false);
// Get initial values from stores
const initialRevenue = computed(() => streamsStore.totalMonthlyAmount || 0);
const initialHours = computed(
() => membersStore.capacityTotals.targetHours || 0
);
const revenue = ref(initialRevenue.value || 0);
const paidHours = ref(initialHours.value || 0);
const winRate = ref(0);
// Watch for store changes and update sliders
watch(initialRevenue, (newVal) => {
if (newVal > 0) revenue.value = newVal;
});
watch(initialHours, (newVal) => {
if (newVal > 0) paidHours.value = newVal;
});
// Calculate dynamic metrics from real store data
const monthlyBurn = computed(() => {
const hourlyWage = policiesStore.equalHourlyWage || 0;
const oncostPct = policiesStore.payrollOncostPct || 0;
const payroll = paidHours.value * hourlyWage * (1 + oncostPct / 100);
const overhead =
budgetStore.overheadCosts.reduce(
(sum, cost) => sum + (cost.amount || 0),
0
) || 0;
const production =
budgetStore.productionCosts.reduce(
(sum, cost) => sum + (cost.amount || 0),
0
) || 0;
return payroll + overhead + production;
});
const calculatedRunway = computed(() => {
const totalCash = cashStore.currentCash + cashStore.currentSavings;
const adjustedRevenue = revenue.value * (winRate.value / 100);
const netPerMonth = adjustedRevenue - monthlyBurn.value;
if (netPerMonth >= 0)
return monthlyBurn.value > 0
? Math.round((totalCash / monthlyBurn.value) * 100) / 100
: 0;
return Math.max(
0,
Math.round((totalCash / Math.abs(netPerMonth)) * 100) / 100
);
});
// Scenario calculations based on real data
const totalCash = computed(
() => cashStore.currentCash + cashStore.currentSavings
);
const baseRunway = computed(() => {
const baseBurn = monthlyBurn.value;
return baseBurn > 0
? Math.round((totalCash.value / baseBurn) * 100) / 100
: 0;
});
const currentScenario = computed(() => ({
runway: baseRunway.value || 0,
}));
const quitDayJobsScenario = computed(() => ({
runway:
monthlyBurn.value > 0
? Math.max(
0,
Math.round((totalCash.value / (monthlyBurn.value * 1.8)) * 100) / 100
)
: 0, // Higher burn rate
}));
const startProductionScenario = computed(() => ({
runway:
monthlyBurn.value > 0
? Math.max(
0,
Math.round((totalCash.value / (monthlyBurn.value * 1.4)) * 100) / 100
)
: 0, // Medium higher burn
}));
const sixMonthScenario = computed(() => ({
runway:
monthlyBurn.value > 0
? Math.max(
0,
Math.round((totalCash.value / (monthlyBurn.value * 0.6)) * 100) / 100
)
: 0, // Lower burn with optimization
}));
// Calculate concentration from real data
const topSourcePct = computed(() => {
if (streamsStore.streams.length === 0) return 0;
const amounts = streamsStore.streams.map((s) => s.targetMonthlyAmount || 0);
const total = amounts.reduce((sum, amt) => sum + amt, 0);
return total > 0 ? Math.round((Math.max(...amounts) / total) * 100) : 0;
});
// Calculate key dates from real data
const keyDates = computed(() => {
const currentDate = new Date();
// Calculate savings gate clear date based on current savings and target
const savingsNeeded =
(policiesStore.savingsTargetMonths || 0) * monthlyBurn.value;
const currentSavings = cashStore.currentSavings;
const monthlyNet = revenue.value - monthlyBurn.value;
let savingsGate = null;
if (savingsNeeded > 0 && currentSavings < savingsNeeded && monthlyNet > 0) {
const monthsToTarget = Math.ceil(
(savingsNeeded - currentSavings) / monthlyNet
);
const targetDate = new Date(currentDate);
targetDate.setMonth(targetDate.getMonth() + monthsToTarget);
savingsGate = targetDate.toLocaleDateString("en-US", {
month: "long",
year: "numeric",
});
}
// First cash breach from cash store projections
const firstBreachWeek = cashStore.firstBreachWeek;
let firstBreach = null;
if (firstBreachWeek) {
const breachDate = new Date(currentDate);
breachDate.setDate(breachDate.getDate() + firstBreachWeek * 7);
firstBreach = `Week ${firstBreachWeek} (${breachDate.toLocaleDateString(
"en-US",
{ month: "short", day: "numeric" }
)})`;
}
// Deferred cap reset - quarterly (every 3 months)
let deferredReset = null;
if (policiesStore.deferredCapHoursPerQtr > 0) {
const nextQuarter = new Date(currentDate);
const currentMonth = nextQuarter.getMonth();
const quarterStartMonth = Math.floor(currentMonth / 3) * 3;
nextQuarter.setMonth(quarterStartMonth + 3, 1);
deferredReset = nextQuarter.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
}
return {
savingsGate,
firstBreach,
deferredReset,
};
});
function getRunwayColor(months: number) {
if (months >= 6) return "text-green-600";
if (months >= 3) return "text-blue-600";
if (months >= 2) return "text-yellow-600";
return "text-red-600";
}
function getProgressColor(months: number) {
if (months >= 6) return "success";
if (months >= 3) return "info";
if (months >= 2) return "warning";
return "error";
}
function setScenario(scenario: string) {
scenariosStore.setActiveScenario(scenario);
router.replace({ query: { ...route.query, scenario } });
}
function resetSliders() {
revenue.value = initialRevenue.value || 0;
paidHours.value = initialHours.value || 0;
winRate.value = 0;
}
async function restartWizard() {
isResetting.value = true;
// Clear all localStorage persistence
if (typeof localStorage !== "undefined") {
localStorage.removeItem("urgent-tools-members");
localStorage.removeItem("urgent-tools-policies");
localStorage.removeItem("urgent-tools-streams");
localStorage.removeItem("urgent-tools-budget");
localStorage.removeItem("urgent-tools-cash");
localStorage.removeItem("urgent-tools-session");
localStorage.removeItem("urgent-tools-scenarios");
}
// Reset all stores
membersStore.resetMembers();
policiesStore.resetPolicies();
streamsStore.resetStreams();
budgetStore.resetBudgetOverhead();
sessionStore.resetSession();
// Reset wizard state
coopBuilderStore.reset();
// Small delay for UX
await new Promise((resolve) => setTimeout(resolve, 300));
isResetting.value = false;
// Navigate to coop planner
await navigateTo("/coop-planner");
}
onMounted(() => {
const q = route.query.scenario;
if (typeof q === "string") {
scenariosStore.setActiveScenario(q);
}
});
</script>