diff --git a/.releases/unreleased/20260218131646-fix-ux-data-visibility-issues.md b/.releases/unreleased/20260218131646-fix-ux-data-visibility-issues.md new file mode 100644 index 00000000..877f0f77 --- /dev/null +++ b/.releases/unreleased/20260218131646-fix-ux-data-visibility-issues.md @@ -0,0 +1,12 @@ +--- +type: minor +area: web +summary: Fix early UX and data visibility issues (forms, time display, workforce clarity, completion feedback) +--- + +- Replace prefilled form values with descriptive placeholders in market orders, production jobs, and workforce +- Add tick countdown timer showing "Next week in Xs" with tooltip explaining time progression +- Reduce health polling from 3s to 15s to minimize UI refresh indicator blinking +- Add comprehensive workforce explanations (capacity impact, allocation labels, help text) +- Add toast notifications for research and production job completions with unlocked recipe details +- Improve overall system transparency and onboarding clarity diff --git a/apps/web/src/components/layout/tick-countdown.tsx b/apps/web/src/components/layout/tick-countdown.tsx new file mode 100644 index 00000000..da99c26d --- /dev/null +++ b/apps/web/src/components/layout/tick-countdown.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Clock } from "lucide-react"; +import { InlineHelp } from "@/components/ui/inline-help"; +import { useWorldHealth } from "./world-health-provider"; + +// Default tick interval (60 seconds) - matches worker default configuration +// TODO: Get this from API configuration endpoint +const DEFAULT_TICK_INTERVAL_MS = 60_000; + +export function TickCountdown() { + const { health } = useWorldHealth(); + const [secondsRemaining, setSecondsRemaining] = useState(null); + + useEffect(() => { + if (!health?.lastAdvancedAt) { + setSecondsRemaining(null); + return; + } + + const updateCountdown = () => { + const lastAdvancedTime = new Date(health.lastAdvancedAt!).getTime(); + const now = Date.now(); + const elapsed = now - lastAdvancedTime; + const remaining = + (DEFAULT_TICK_INTERVAL_MS - (elapsed % DEFAULT_TICK_INTERVAL_MS)) % + DEFAULT_TICK_INTERVAL_MS; + setSecondsRemaining(Math.ceil(remaining / 1000)); + }; + + updateCountdown(); + const interval = setInterval(updateCountdown, 1000); + + return () => clearInterval(interval); + }, [health?.lastAdvancedAt, health?.currentTick]); + + if (secondsRemaining === null) { + return null; + } + + const helpText = `Time progression: The simulation advances in discrete weeks (ticks). Each week represents ${DEFAULT_TICK_INTERVAL_MS / 1000} seconds of real time. Production jobs, research, and shipments complete when the required number of weeks pass.`; + + return ( +
+ + + Next week in {secondsRemaining}s + + +
+ ); +} diff --git a/apps/web/src/components/layout/top-bar.tsx b/apps/web/src/components/layout/top-bar.tsx index 695cd3d3..d86e9101 100644 --- a/apps/web/src/components/layout/top-bar.tsx +++ b/apps/web/src/components/layout/top-bar.tsx @@ -15,6 +15,7 @@ import { useControlManager } from "./control-manager"; import { PROFILE_PANEL_ID } from "./profile-panel"; import { StatusIndicator } from "./status-indicator"; import { UiSfxSettings } from "./ui-sfx-settings"; +import { TickCountdown } from "./tick-countdown"; import { useWorldHealth } from "./world-health-provider"; export function TopBar() { @@ -38,7 +39,10 @@ export function TopBar() {

{TOP_BAR_TITLES[pathname] ?? "CorpSim"}

-

{formatCadencePoint(health?.currentTick)}

+
+

{formatCadencePoint(health?.currentTick)}

+ +
diff --git a/apps/web/src/components/market/order-placement-card.tsx b/apps/web/src/components/market/order-placement-card.tsx index 1fe6053d..8f74b7a8 100644 --- a/apps/web/src/components/market/order-placement-card.tsx +++ b/apps/web/src/components/market/order-placement-card.tsx @@ -30,8 +30,8 @@ export function OrderPlacementCard({ const [side, setSide] = useState<"BUY" | "SELL">("BUY"); const [selectedItemId, setSelectedItemId] = useState(""); const [itemSearch, setItemSearch] = useState(""); - const [priceInput, setPriceInput] = useState("1.00"); - const [quantityInput, setQuantityInput] = useState("1"); + const [priceInput, setPriceInput] = useState(""); + const [quantityInput, setQuantityInput] = useState(""); const [error, setError] = useState(null); const deferredItemSearch = useDeferredValue(itemSearch); @@ -144,7 +144,7 @@ export function OrderPlacementCard({ setQuantityInput(event.target.value)} - placeholder="1" + placeholder="Enter quantity (e.g., 100)" />
@@ -181,7 +181,7 @@ export function OrderPlacementCard({ setPriceInput(event.target.value)} - placeholder="1.00" + placeholder="Enter price (e.g., 1.50)" />

Enter dollars (for example, 0.80). The order is stored in cents. diff --git a/apps/web/src/components/production/production-page.tsx b/apps/web/src/components/production/production-page.tsx index 943033fd..9e5e6c38 100644 --- a/apps/web/src/components/production/production-page.tsx +++ b/apps/web/src/components/production/production-page.tsx @@ -78,7 +78,7 @@ export function ProductionPage() { const [recipePage, setRecipePage] = useState(1); const [recipePageSize, setRecipePageSize] = useState<(typeof PRODUCTION_RECIPE_PAGE_SIZE_OPTIONS)[number]>(10); - const [quantityInput, setQuantityInput] = useState("1"); + const [quantityInput, setQuantityInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [isLoadingRecipes, setIsLoadingRecipes] = useState(true); const [isLoadingJobs, setIsLoadingJobs] = useState(true); @@ -331,18 +331,33 @@ export function ProductionPage() { return; } - let hasNewCompletion = false; - for (const jobId of nextIds) { - if (!completedJobIdsRef.current.has(jobId)) { - hasNewCompletion = true; - break; + const newlyCompleted: ProductionJob[] = []; + for (const job of completedJobs) { + if (!completedJobIdsRef.current.has(job.id)) { + newlyCompleted.push(job); } } - if (hasNewCompletion) { + if (newlyCompleted.length > 0) { play("event_production_completed"); + + // Show toast notification for completed jobs + if (newlyCompleted.length === 1) { + const job = newlyCompleted[0]; + showToast({ + title: "Production Complete", + description: `Produced ${job.quantity} × ${job.recipe.outputItem.name}`, + variant: "success" + }); + } else { + showToast({ + title: "Production Complete", + description: `${newlyCompleted.length} production jobs completed`, + variant: "success" + }); + } } completedJobIdsRef.current = nextIds; - }, [completedJobs, play]); + }, [completedJobs, play, showToast]); const showInitialRecipesSkeleton = isLoadingRecipes && !hasLoadedRecipes; const showInitialJobsSkeleton = isLoadingJobs && !hasLoadedJobs; @@ -614,7 +629,7 @@ export function ProductionPage() { setQuantityInput(event.target.value)} - placeholder="1" + placeholder="Enter number of runs (e.g., 10)" /> diff --git a/apps/web/src/components/research/research-page.tsx b/apps/web/src/components/research/research-page.tsx index 056a3481..5db85ee5 100644 --- a/apps/web/src/components/research/research-page.tsx +++ b/apps/web/src/components/research/research-page.tsx @@ -195,6 +195,8 @@ export function ResearchPage() { didPrimeStatusesRef.current = false; }, [activeCompanyId]); + const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node] as const)), [nodes]); + useEffect(() => { const nextStatusById = new Map(nodes.map((node) => [node.id, node.status] as const)); if (!didPrimeStatusesRef.current) { @@ -203,21 +205,50 @@ export function ResearchPage() { return; } - let hasNewCompletion = false; + const completedNodes: ResearchNode[] = []; for (const [nodeId, nextStatus] of nextStatusById.entries()) { const previousStatus = statusByNodeIdRef.current.get(nodeId); if (previousStatus !== "COMPLETED" && nextStatus === "COMPLETED") { - hasNewCompletion = true; - break; + const node = nodeById.get(nodeId); + if (node) { + completedNodes.push(node); + } } } - if (hasNewCompletion) { + if (completedNodes.length > 0) { play("event_research_completed"); + + // Show toast notification for completed research + const unlockedRecipes = completedNodes.flatMap((node) => node.unlockRecipes); + const uniqueRecipeNamesSet = new Set( + unlockedRecipes + .map((recipe) => recipe.recipeName) + .filter((name): name is string => Boolean(name && name.trim())) + ); + const uniqueRecipeNames = Array.from(uniqueRecipeNamesSet); + const MAX_RECIPES_IN_TOAST = 3; + let message: string; + + if (uniqueRecipeNames.length > 0) { + const displayedNames = uniqueRecipeNames.slice(0, MAX_RECIPES_IN_TOAST); + const remainingCount = uniqueRecipeNames.length - displayedNames.length; + const baseList = displayedNames.join(", "); + const summary = + remainingCount > 0 ? `${baseList} + ${remainingCount} more` : baseList; + message = `Research complete! Unlocked recipes: ${summary}`; + } else { + message = "Research complete!"; + } + + showToast({ + title: completedNodes.length === 1 ? completedNodes[0].name : "Research Complete", + description: message, + variant: "success" + }); } statusByNodeIdRef.current = nextStatusById; - }, [nodes, play]); + }, [nodes, play, showToast, nodeById]); - const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node] as const)), [nodes]); const selectedNode = selectedNodeId ? nodeById.get(selectedNodeId) ?? null : null; const filteredNodes = useMemo(() => { diff --git a/apps/web/src/components/workforce/workforce-page.tsx b/apps/web/src/components/workforce/workforce-page.tsx index ffe3dce0..cf3e6596 100644 --- a/apps/web/src/components/workforce/workforce-page.tsx +++ b/apps/web/src/components/workforce/workforce-page.tsx @@ -52,12 +52,12 @@ export function WorkforcePage() { const { health } = useWorldHealth(); const [workforce, setWorkforce] = useState(null); const [allocationDraft, setAllocationDraft] = useState({ - operationsPct: "40", - researchPct: "20", - logisticsPct: "20", - corporatePct: "20" + operationsPct: "", + researchPct: "", + logisticsPct: "", + corporatePct: "" }); - const [capacityDeltaInput, setCapacityDeltaInput] = useState("0"); + const [capacityDeltaInput, setCapacityDeltaInput] = useState(""); const [isLoading, setIsLoading] = useState(false); const [hasLoadedWorkforce, setHasLoadedWorkforce] = useState(false); const [isSavingAllocation, setIsSavingAllocation] = useState(false); @@ -207,7 +207,7 @@ export function WorkforcePage() { deltaCapacity }); await loadWorkforce(); - setCapacityDeltaInput("0"); + setCapacityDeltaInput(""); setError(null); showToast({ title: "Capacity request submitted", @@ -246,6 +246,9 @@ export function WorkforcePage() { Organizational Capacity +

+ Workforce capacity determines production speed and research efficiency. Higher capacity allows faster operations, but increases weekly salary costs. Allocation percentages control which departments receive speed bonuses. +

@@ -278,42 +281,69 @@ export function WorkforcePage() { Allocation Controls +

+ Distribute your workforce across departments. Higher allocation in each area provides speed bonuses. Total must equal 100%. +

- - setAllocationDraft((current) => ({ ...current, operationsPct: event.target.value })) - } - placeholder="Operations %" - inputMode="numeric" - /> - - setAllocationDraft((current) => ({ ...current, researchPct: event.target.value })) - } - placeholder="Research %" - inputMode="numeric" - /> - - setAllocationDraft((current) => ({ ...current, logisticsPct: event.target.value })) - } - placeholder="Logistics %" - inputMode="numeric" - /> - - setAllocationDraft((current) => ({ ...current, corporatePct: event.target.value })) - } - placeholder="Corporate %" - inputMode="numeric" - /> -
@@ -345,7 +375,7 @@ export function WorkforcePage() { setCapacityDeltaInput(event.target.value)} - placeholder="Delta capacity" + placeholder="Enter change (e.g., +50 or -20)" inputMode="numeric" />