Overhaul dashboard into command center with compute tracking, era-gated sections
CI / build-and-push (push) Successful in 37s
CI / build-and-push (push) Successful in 37s
Add compute history time-series (capacity vs demand chart), revenue vs expenses dual-line chart, enhanced system status (training allocation, network uptime, model freshness), active operations panel, market position bars, and competitor snapshot. Stat cards expand from 3 to 6 as player progresses through eras. Graceful v9→v10 save migration preserves existing games. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,27 +1,94 @@
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatMoney, formatNumber, formatPercent } from '@ai-tycoon/shared';
|
||||
import type React from 'react';
|
||||
import { useGameStore, type ActivePage } from '@/store';
|
||||
import { formatMoney, formatNumber, formatPercent, formatDuration } from '@ai-tycoon/shared';
|
||||
import type { Era } from '@ai-tycoon/shared';
|
||||
import { TECH_TREE } from '@ai-tycoon/game-engine';
|
||||
import {
|
||||
DollarSign, Server, Brain, Users, TrendingUp,
|
||||
TrendingDown, Minus, Cpu, Zap, Shield, ChevronRight,
|
||||
DollarSign, TrendingUp, TrendingDown, Minus, Cpu, Brain, Users,
|
||||
Shield, ChevronRight, Zap, Wifi, Sparkles, FlaskConical, Building2,
|
||||
HardDrive, Clock,
|
||||
} from 'lucide-react';
|
||||
import { XAxis, YAxis, Tooltip, ResponsiveContainer, Area, AreaChart } from 'recharts';
|
||||
import {
|
||||
XAxis, YAxis, Tooltip, ResponsiveContainer, Area, AreaChart, Line, LineChart,
|
||||
} from 'recharts';
|
||||
import { TutorialHint } from '@/components/game/TutorialHint';
|
||||
|
||||
const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||
|
||||
function isEraAtLeast(current: Era, threshold: Era): boolean {
|
||||
return ERA_ORDER.indexOf(current) >= ERA_ORDER.indexOf(threshold);
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const money = useGameStore((s) => s.economy.money);
|
||||
const revenuePerTick = useGameStore((s) => s.economy.revenuePerTick);
|
||||
const expensesPerTick = useGameStore((s) => s.economy.expensesPerTick);
|
||||
const financialHistory = useGameStore((s) => s.economy.financialHistory);
|
||||
const totalFlops = useGameStore((s) => s.infrastructure.totalFlops);
|
||||
const totalDCs = useGameStore((s) => s.infrastructure.totalDataCenterCount);
|
||||
const clusters = useGameStore((s) => s.infrastructure.clusters);
|
||||
const baseModels = useGameStore((s) => s.models.baseModels);
|
||||
const activePipelines = useGameStore((s) => s.models.activeTrainingPipelines);
|
||||
const subscribers = useGameStore((s) => s.market.consumerTiers.totalUsers);
|
||||
const satisfaction = useGameStore((s) => s.market.consumerTiers.satisfaction);
|
||||
const reputation = useGameStore((s) => s.reputation.score);
|
||||
const reputationHistory = useGameStore((s) => s.reputation.reputationHistory);
|
||||
const inferenceUtil = useGameStore((s) => s.compute.inferenceUtilization);
|
||||
const financialHistory = useGameStore((s) => s.economy.financialHistory);
|
||||
const effectiveInferenceFlops = useGameStore((s) => s.compute.effectiveInferenceFlops);
|
||||
const trainingAllocation = useGameStore((s) => s.compute.trainingAllocation);
|
||||
const computeHistory = useGameStore((s) => s.compute.computeHistory);
|
||||
const totalUptime = useGameStore((s) => s.infrastructure.totalUptime);
|
||||
const modelFreshness = useGameStore((s) => s.market.obsolescence.playerModelFreshness);
|
||||
const era = useGameStore((s) => s.meta.currentEra);
|
||||
const activeResearch = useGameStore((s) => s.research.activeResearch);
|
||||
const tam = useGameStore((s) => s.market.tam);
|
||||
const competitors = useGameStore((s) => s.competitors.rivals);
|
||||
|
||||
const netIncome = revenuePerTick - expensesPerTick;
|
||||
const scaleup = isEraAtLeast(era, 'scaleup');
|
||||
const hasDeployedModel = baseModels.some(m => m.isDeployed);
|
||||
|
||||
const bestDeployedCapability = baseModels
|
||||
.filter(m => m.isDeployed)
|
||||
.reduce((best, m) => Math.max(best, m.rawCapability), 0);
|
||||
|
||||
const revenueTrend = (() => {
|
||||
if (financialHistory.length < 6) return 'neutral' as const;
|
||||
const recent = financialHistory[financialHistory.length - 1].revenue;
|
||||
const earlier = financialHistory[financialHistory.length - 6].revenue;
|
||||
if (recent > earlier * 1.01) return 'up' as const;
|
||||
if (recent < earlier * 0.99) return 'down' as const;
|
||||
return 'neutral' as const;
|
||||
})();
|
||||
|
||||
const repTrend = (() => {
|
||||
if (reputationHistory.length < 2) return 'neutral' as const;
|
||||
const last = reputationHistory[reputationHistory.length - 1].score;
|
||||
const prev = reputationHistory[reputationHistory.length - 2].score;
|
||||
if (last > prev) return 'up' as const;
|
||||
if (last < prev) return 'down' as const;
|
||||
return 'neutral' as const;
|
||||
})();
|
||||
|
||||
const constructingDCs = clusters.reduce((count, cluster) => {
|
||||
for (const campus of cluster.campuses) {
|
||||
for (const dc of campus.dataCenters) {
|
||||
if (dc.status === 'constructing') count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}, 0);
|
||||
|
||||
const deployingRacks = clusters.reduce((count, cluster) => {
|
||||
for (const campus of cluster.campuses) {
|
||||
for (const dc of campus.dataCenters) {
|
||||
count += dc.deploymentCohorts.length;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}, 0);
|
||||
|
||||
const navigate = useGameStore.getState().setActivePage;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -45,7 +112,8 @@ export function DashboardPage() {
|
||||
</TutorialHint>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{/* Section 1: Stat Cards */}
|
||||
<div className={`grid gap-4 ${scaleup ? 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6' : 'grid-cols-3'}`}>
|
||||
<StatCard
|
||||
icon={DollarSign}
|
||||
label="Cash"
|
||||
@@ -53,63 +121,139 @@ export function DashboardPage() {
|
||||
subValue={`${netIncome >= 0 ? '+' : ''}${formatMoney(netIncome)}/s`}
|
||||
trend={netIncome > 0 ? 'up' : netIncome < 0 ? 'down' : 'neutral'}
|
||||
color="text-green-400"
|
||||
onClick={() => useGameStore.getState().setActivePage('finance')}
|
||||
onClick={() => navigate('finance')}
|
||||
/>
|
||||
{scaleup && (
|
||||
<StatCard
|
||||
icon={TrendingUp}
|
||||
label="Revenue"
|
||||
value={`${formatMoney(revenuePerTick)}/s`}
|
||||
subValue={`${formatMoney(revenuePerTick * 3600)}/hr`}
|
||||
trend={revenueTrend}
|
||||
color="text-emerald-400"
|
||||
onClick={() => navigate('finance')}
|
||||
/>
|
||||
)}
|
||||
<StatCard
|
||||
icon={Server}
|
||||
label="Data Centers"
|
||||
value={totalDCs.toString()}
|
||||
subValue={`${formatNumber(totalFlops)} FLOPS`}
|
||||
color="text-blue-400"
|
||||
onClick={() => useGameStore.getState().setActivePage('infrastructure')}
|
||||
icon={Cpu}
|
||||
label="Compute"
|
||||
value={`${formatPercent(inferenceUtil)} util`}
|
||||
subValue={`${formatNumber(effectiveInferenceFlops)} FLOPS`}
|
||||
trend={inferenceUtil > 0.9 ? 'down' : inferenceUtil < 0.5 ? 'up' : 'neutral'}
|
||||
color="text-cyan-400"
|
||||
onClick={() => navigate('infrastructure')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Brain}
|
||||
label="Models"
|
||||
value={baseModels.length.toString()}
|
||||
subValue={activePipelines.filter(p => p.status === 'active').length > 0 ? `Training: ${activePipelines.filter(p => p.status === 'active').length} active` : 'Idle'}
|
||||
subValue={
|
||||
activePipelines.filter(p => p.status === 'active').length > 0
|
||||
? `Training: ${activePipelines.filter(p => p.status === 'active').length} active`
|
||||
: hasDeployedModel
|
||||
? `Best: ${bestDeployedCapability.toFixed(1)}`
|
||||
: 'Idle'
|
||||
}
|
||||
color="text-purple-400"
|
||||
onClick={() => useGameStore.getState().setActivePage('models')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Users}
|
||||
label="Subscribers"
|
||||
value={formatNumber(subscribers)}
|
||||
subValue={`Satisfaction: ${formatPercent(useGameStore.getState().market.consumerTiers.satisfaction)}`}
|
||||
color="text-orange-400"
|
||||
onClick={() => useGameStore.getState().setActivePage('market')}
|
||||
onClick={() => navigate('models')}
|
||||
/>
|
||||
{scaleup && (
|
||||
<StatCard
|
||||
icon={Users}
|
||||
label="Subscribers"
|
||||
value={formatNumber(subscribers)}
|
||||
subValue={`Satisfaction: ${formatPercent(satisfaction)}`}
|
||||
color="text-orange-400"
|
||||
onClick={() => navigate('market')}
|
||||
/>
|
||||
)}
|
||||
{scaleup && (
|
||||
<StatCard
|
||||
icon={Shield}
|
||||
label="Reputation"
|
||||
value={`${reputation}/100`}
|
||||
trend={repTrend}
|
||||
color="text-violet-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Section 2: Primary Charts */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<h3 className="text-sm font-medium text-surface-400 mb-4">Revenue Over Time</h3>
|
||||
{financialHistory.length > 1 ? (
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<h3 className="text-sm font-medium text-surface-400">Compute: Capacity vs Demand</h3>
|
||||
<div className="flex items-center gap-3 ml-auto text-[10px]">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-cyan-400" />Capacity</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-amber-400" />Demand</span>
|
||||
</div>
|
||||
</div>
|
||||
{computeHistory.length > 1 ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={financialHistory}>
|
||||
<AreaChart data={computeHistory}>
|
||||
<defs>
|
||||
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="#22c55e" stopOpacity={0} />
|
||||
<linearGradient id="computeCapGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22d3ee" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="#22d3ee" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="computeDemGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#f59e0b" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="#f59e0b" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="tick" hide />
|
||||
<YAxis width={50} tickFormatter={(v: number) => formatMoney(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
||||
<YAxis width={55} tickFormatter={(v: number) => `${formatNumber(v)}`} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
|
||||
labelStyle={{ color: '#94a3b8' }}
|
||||
formatter={(value: number) => [formatMoney(value), 'Revenue']}
|
||||
formatter={(value: number, name: string) => {
|
||||
const label = name === 'tokensPerSecondCapacity' ? 'Capacity' : 'Demand';
|
||||
return [`${formatNumber(value)} tok/s`, label];
|
||||
}}
|
||||
/>
|
||||
<Area type="monotone" dataKey="revenue" stroke="#22c55e" fill="url(#revenueGrad)" />
|
||||
<Area type="monotone" dataKey="tokensPerSecondCapacity" stroke="#22d3ee" fill="url(#computeCapGrad)" />
|
||||
<Area type="monotone" dataKey="tokensPerSecondDemand" stroke="#f59e0b" fill="url(#computeDemGrad)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[200px] flex items-center justify-center text-surface-500 text-sm">
|
||||
No data yet — start earning revenue
|
||||
No compute data yet — deploy racks to start tracking
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<h3 className="text-sm font-medium text-surface-400">Revenue vs Expenses</h3>
|
||||
<div className="flex items-center gap-3 ml-auto text-[10px]">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-green-500" />Revenue</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-red-500" />Expenses</span>
|
||||
</div>
|
||||
</div>
|
||||
{financialHistory.length > 1 ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={financialHistory}>
|
||||
<XAxis dataKey="tick" hide />
|
||||
<YAxis width={55} tickFormatter={(v: number) => formatMoney(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
|
||||
labelStyle={{ color: '#94a3b8' }}
|
||||
formatter={(value: number, name: string) => [formatMoney(value), name === 'revenue' ? 'Revenue' : 'Expenses']}
|
||||
/>
|
||||
<Line type="monotone" dataKey="revenue" stroke="#22c55e" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="expenses" stroke="#ef4444" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[200px] flex items-center justify-center text-surface-500 text-sm">
|
||||
No financial data yet — start earning revenue
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 3: System Health + Active Operations */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<h3 className="text-sm font-medium text-surface-400 mb-4">System Status</h3>
|
||||
<div className="space-y-4">
|
||||
@@ -120,80 +264,139 @@ export function DashboardPage() {
|
||||
bar={inferenceUtil}
|
||||
barColor={inferenceUtil > 0.9 ? 'bg-danger' : inferenceUtil > 0.7 ? 'bg-warning' : 'bg-success'}
|
||||
/>
|
||||
<StatusRow
|
||||
icon={Zap}
|
||||
label="Training Allocation"
|
||||
value={formatPercent(trainingAllocation)}
|
||||
bar={trainingAllocation}
|
||||
barColor="bg-cyan-500"
|
||||
/>
|
||||
{scaleup && (
|
||||
<StatusRow
|
||||
icon={Wifi}
|
||||
label="Network Uptime"
|
||||
value={formatPercent(totalUptime)}
|
||||
bar={totalUptime}
|
||||
barColor={totalUptime < 0.95 ? 'bg-danger' : totalUptime < 0.99 ? 'bg-warning' : 'bg-success'}
|
||||
/>
|
||||
)}
|
||||
{scaleup && hasDeployedModel && (
|
||||
<StatusRow
|
||||
icon={Sparkles}
|
||||
label="Model Freshness"
|
||||
value={formatPercent(modelFreshness)}
|
||||
bar={modelFreshness}
|
||||
barColor={modelFreshness < 0.3 ? 'bg-danger' : modelFreshness < 0.6 ? 'bg-warning' : 'bg-success'}
|
||||
/>
|
||||
)}
|
||||
<StatusRow
|
||||
icon={Shield}
|
||||
label="Reputation"
|
||||
value={`${reputation}/100`}
|
||||
bar={reputation / 100}
|
||||
barColor={reputation > 70 ? 'bg-success' : reputation > 40 ? 'bg-warning' : 'bg-danger'}
|
||||
/>
|
||||
<StatusRow
|
||||
icon={Zap}
|
||||
label="Compute"
|
||||
value={`${formatNumber(totalFlops)} FLOPS`}
|
||||
bar={Math.min(1, totalFlops / 100)}
|
||||
barColor="bg-accent"
|
||||
barColor={reputation < 40 ? 'bg-danger' : reputation < 70 ? 'bg-warning' : 'bg-success'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<h3 className="text-sm font-medium text-surface-400 mb-4">Subscribers Over Time</h3>
|
||||
{(useGameStore.getState().market.subscriberHistory?.length ?? 0) > 1 ? (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart data={useGameStore.getState().market.subscriberHistory}>
|
||||
<defs>
|
||||
<linearGradient id="subsGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#f97316" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="#f97316" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="tick" hide />
|
||||
<YAxis width={50} tickFormatter={(v: number) => formatNumber(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
|
||||
formatter={(value: number) => [formatNumber(value), 'Subscribers']}
|
||||
/>
|
||||
<Area type="monotone" dataKey="subscribers" stroke="#f97316" fill="url(#subsGrad)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[180px] flex items-center justify-center text-surface-500 text-sm">
|
||||
No subscriber data yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<h3 className="text-sm font-medium text-surface-400 mb-4">Reputation Over Time</h3>
|
||||
{(useGameStore.getState().reputation.reputationHistory?.length ?? 0) > 1 ? (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart data={useGameStore.getState().reputation.reputationHistory}>
|
||||
<defs>
|
||||
<linearGradient id="repGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#a855f7" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="#a855f7" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="tick" hide />
|
||||
<YAxis width={30} domain={[0, 100]} tickFormatter={(v: number) => `${v}`} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
|
||||
formatter={(value: number) => [`${value}/100`, 'Reputation']}
|
||||
/>
|
||||
<Area type="monotone" dataKey="score" stroke="#a855f7" fill="url(#repGrad)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[180px] flex items-center justify-center text-surface-500 text-sm">
|
||||
No reputation data yet
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-sm font-medium text-surface-400 mb-4">Active Operations</h3>
|
||||
<ActiveOperations
|
||||
pipelines={activePipelines}
|
||||
activeResearch={activeResearch}
|
||||
constructingDCs={constructingDCs}
|
||||
deployingRacks={deployingRacks}
|
||||
navigate={navigate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 4: Secondary Charts (scaleup+) */}
|
||||
{scaleup && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<h3 className="text-sm font-medium text-surface-400 mb-4">Subscribers Over Time</h3>
|
||||
{(useGameStore.getState().market.subscriberHistory?.length ?? 0) > 1 ? (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart data={useGameStore.getState().market.subscriberHistory}>
|
||||
<defs>
|
||||
<linearGradient id="subsGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#f97316" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="#f97316" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="tick" hide />
|
||||
<YAxis width={50} tickFormatter={(v: number) => formatNumber(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
|
||||
formatter={(value: number) => [formatNumber(value), 'Subscribers']}
|
||||
/>
|
||||
<Area type="monotone" dataKey="subscribers" stroke="#f97316" fill="url(#subsGrad)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[180px] flex items-center justify-center text-surface-500 text-sm">
|
||||
No subscriber data yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<h3 className="text-sm font-medium text-surface-400 mb-4">Market Position</h3>
|
||||
<div className="space-y-3">
|
||||
<MarketShareBar label="Consumer" color="bg-orange-400" segment={tam.segments.consumer} />
|
||||
<MarketShareBar label="Developer" color="bg-blue-400" segment={tam.segments.developer} />
|
||||
<MarketShareBar label="Enterprise" color="bg-purple-400" segment={tam.segments.enterprise} />
|
||||
<MarketShareBar label="Government" color="bg-surface-400" segment={tam.segments.government} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 5: Competitor Snapshot (scaleup+) */}
|
||||
{scaleup && competitors.filter(r => r.status === 'active').length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-surface-400 mb-3">Competitors</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{competitors.filter(r => r.status === 'active').map(rival => (
|
||||
<div key={rival.id} className="bg-surface-900 border border-surface-700 rounded-xl p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">{rival.name}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-700 text-surface-400">
|
||||
{rival.archetype.replace('-', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between text-xs text-surface-400 mb-1">
|
||||
<span>Capability</span>
|
||||
<span className="font-mono">{rival.estimatedCapability.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-surface-800 rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className="h-full bg-accent rounded-full"
|
||||
style={{ width: `${Math.min(100, bestDeployedCapability)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-1.5 bg-surface-800 rounded-full overflow-hidden mt-1">
|
||||
<div
|
||||
className="h-full bg-danger/70 rounded-full"
|
||||
style={{ width: `${Math.min(100, rival.estimatedCapability)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-surface-500 mt-0.5">
|
||||
<span>You: {bestDeployedCapability.toFixed(1)}</span>
|
||||
<span>Them: {rival.estimatedCapability.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-surface-500">
|
||||
Latest: <span className="text-surface-300">{rival.latestModelName || 'None'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalDCs === 0 && (
|
||||
<div className="bg-surface-900 border border-accent/30 rounded-xl p-6 text-center">
|
||||
<h3 className="text-lg font-semibold mb-2">Get Started</h3>
|
||||
@@ -201,7 +404,7 @@ export function DashboardPage() {
|
||||
Build your first data center to start training AI models.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => useGameStore.getState().setActivePage('infrastructure')}
|
||||
onClick={() => navigate('infrastructure')}
|
||||
className="bg-accent hover:bg-accent-dark text-white font-medium px-6 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Build Data Center
|
||||
@@ -212,6 +415,151 @@ export function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveOperations({
|
||||
pipelines,
|
||||
activeResearch,
|
||||
constructingDCs,
|
||||
deployingRacks,
|
||||
navigate,
|
||||
}: {
|
||||
pipelines: { modelName: string; status: string; currentStage: string; stages: Record<string, { progressTicks: number; totalTicks: number; isComplete: boolean }> }[];
|
||||
activeResearch: { researchId: string; progressTicks: number; totalTicks: number } | null;
|
||||
constructingDCs: number;
|
||||
deployingRacks: number;
|
||||
navigate: (page: ActivePage) => void;
|
||||
}) {
|
||||
const activePipes = pipelines.filter(p => p.status === 'active');
|
||||
const items: React.ReactNode[] = [];
|
||||
|
||||
for (let i = 0; i < Math.min(2, activePipes.length); i++) {
|
||||
const p = activePipes[i];
|
||||
const stage = p.stages[p.currentStage as keyof typeof p.stages];
|
||||
const progress = stage ? stage.progressTicks / stage.totalTicks : 0;
|
||||
const eta = stage ? stage.totalTicks - stage.progressTicks : 0;
|
||||
items.push(
|
||||
<OperationRow
|
||||
key={`train-${i}`}
|
||||
icon={Brain}
|
||||
label={p.modelName}
|
||||
detail={p.currentStage}
|
||||
progress={progress}
|
||||
eta={eta}
|
||||
onClick={() => navigate('models')}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (activePipes.length > 2) {
|
||||
items.push(
|
||||
<button key="more-train" onClick={() => navigate('models')} className="text-xs text-accent hover:text-accent-light transition-colors">
|
||||
+{activePipes.length - 2} more training...
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
|
||||
if (activeResearch) {
|
||||
const researchName = TECH_TREE.find(n => n.id === activeResearch.researchId)?.name ?? activeResearch.researchId;
|
||||
const progress = activeResearch.progressTicks / activeResearch.totalTicks;
|
||||
const eta = activeResearch.totalTicks - activeResearch.progressTicks;
|
||||
items.push(
|
||||
<OperationRow
|
||||
key="research"
|
||||
icon={FlaskConical}
|
||||
label={researchName}
|
||||
detail="researching"
|
||||
progress={progress}
|
||||
eta={eta}
|
||||
onClick={() => navigate('research')}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (constructingDCs > 0) {
|
||||
items.push(
|
||||
<div key="construction" className="flex items-center gap-2 py-1 cursor-pointer hover:bg-surface-800/50 rounded px-1 -mx-1 transition-colors" onClick={() => navigate('infrastructure')}>
|
||||
<Building2 size={14} className="text-surface-400" />
|
||||
<span className="text-sm text-surface-300">{constructingDCs} DC{constructingDCs > 1 ? 's' : ''} building</span>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
if (deployingRacks > 0) {
|
||||
items.push(
|
||||
<div key="deploy" className="flex items-center gap-2 py-1 cursor-pointer hover:bg-surface-800/50 rounded px-1 -mx-1 transition-colors" onClick={() => navigate('infrastructure')}>
|
||||
<HardDrive size={14} className="text-surface-400" />
|
||||
<span className="text-sm text-surface-300">{deployingRacks} rack batch{deployingRacks > 1 ? 'es' : ''} deploying</span>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-surface-500 text-sm min-h-[100px]">
|
||||
<div className="text-center">
|
||||
<Clock size={20} className="mx-auto mb-2 text-surface-600" />
|
||||
All systems idle
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="space-y-3">{items}</div>;
|
||||
}
|
||||
|
||||
function OperationRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
detail,
|
||||
progress,
|
||||
eta,
|
||||
onClick,
|
||||
}: {
|
||||
icon: typeof Brain;
|
||||
label: string;
|
||||
detail: string;
|
||||
progress: number;
|
||||
eta: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="cursor-pointer hover:bg-surface-800/50 rounded px-1 -mx-1 py-1 transition-colors" onClick={onClick}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={14} className="text-surface-400" />
|
||||
<span className="text-sm text-surface-300 truncate max-w-[140px]">{label}</span>
|
||||
<span className="text-[10px] text-surface-500">{detail}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-surface-500">{formatDuration(eta)}</span>
|
||||
</div>
|
||||
<div className="h-1 bg-surface-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-accent rounded-full transition-all duration-500" style={{ width: `${Math.min(100, progress * 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarketShareBar({
|
||||
label,
|
||||
color,
|
||||
segment,
|
||||
}: {
|
||||
label: string;
|
||||
color: string;
|
||||
segment: { shares: { playerId: string; sharePercent: number }[] };
|
||||
}) {
|
||||
const playerShare = segment.shares.find(s => s.playerId === 'player')?.sharePercent ?? 0;
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-surface-400">{label}</span>
|
||||
<span className="text-xs font-mono text-surface-300">{formatPercent(playerShare / 100)}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-surface-800 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all duration-500 ${color}`} style={{ width: `${Math.min(100, playerShare)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon: Icon, label, value, subValue, trend, color, onClick,
|
||||
}: {
|
||||
|
||||
Reference in New Issue
Block a user