Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions apps/web/src/components/layout/tick-countdown.tsx
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
BENZOOgataga marked this conversation as resolved.

export function TickCountdown() {
const { health } = useWorldHealth();
const [secondsRemaining, setSecondsRemaining] = useState<number | null>(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 (
<div className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
<span title={helpText}>
Next week in {secondsRemaining}s
</span>
<InlineHelp label={helpText} />
</div>
);
}
6 changes: 5 additions & 1 deletion apps/web/src/components/layout/top-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -38,7 +39,10 @@ export function TopBar() {
<div className="flex h-14 items-center justify-between gap-3 px-4">
<div className="min-w-0">
<h1 className="text-base font-semibold">{TOP_BAR_TITLES[pathname] ?? "CorpSim"}</h1>
<p className="text-xs text-muted-foreground">{formatCadencePoint(health?.currentTick)}</p>
<div className="flex items-center gap-3">
<p className="text-xs text-muted-foreground">{formatCadencePoint(health?.currentTick)}</p>
<TickCountdown />
</div>
</div>
<div className="flex items-center gap-3">
<ActiveCompanyCombobox />
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/components/market/order-placement-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ export function OrderPlacementCard({
const [side, setSide] = useState<"BUY" | "SELL">("BUY");
const [selectedItemId, setSelectedItemId] = useState<string>("");
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<string | null>(null);
const deferredItemSearch = useDeferredValue(itemSearch);

Expand Down Expand Up @@ -144,7 +144,7 @@ export function OrderPlacementCard({
<Input
value={quantityInput}
onChange={(event) => setQuantityInput(event.target.value)}
placeholder="1"
placeholder="Enter quantity (e.g., 100)"
/>
</div>
</div>
Expand Down Expand Up @@ -181,7 +181,7 @@ export function OrderPlacementCard({
<Input
value={priceInput}
onChange={(event) => setPriceInput(event.target.value)}
placeholder="1.00"
placeholder="Enter price (e.g., 1.50)"
/>
<p className="mt-1 text-xs text-muted-foreground">
Enter dollars (for example, 0.80). The order is stored in cents.
Expand Down
33 changes: 24 additions & 9 deletions apps/web/src/components/production/production-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -614,7 +629,7 @@ export function ProductionPage() {
<Input
value={quantityInput}
onChange={(event) => setQuantityInput(event.target.value)}
placeholder="1"
placeholder="Enter number of runs (e.g., 10)"
/>
</div>

Expand Down
43 changes: 37 additions & 6 deletions apps/web/src/components/research/research-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<string>(
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!";
}

Comment thread
BENZOOgataga marked this conversation as resolved.
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(() => {
Expand Down
110 changes: 70 additions & 40 deletions apps/web/src/components/workforce/workforce-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ export function WorkforcePage() {
const { health } = useWorldHealth();
const [workforce, setWorkforce] = useState<CompanyWorkforce | null>(null);
const [allocationDraft, setAllocationDraft] = useState<AllocationDraft>({
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);
Expand Down Expand Up @@ -207,7 +207,7 @@ export function WorkforcePage() {
deltaCapacity
});
await loadWorkforce();
setCapacityDeltaInput("0");
setCapacityDeltaInput("");
setError(null);
showToast({
title: "Capacity request submitted",
Expand Down Expand Up @@ -246,6 +246,9 @@ export function WorkforcePage() {
<Card>
<CardHeader>
<CardTitle>Organizational Capacity</CardTitle>
<p className="text-sm text-muted-foreground">
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.
</p>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-md border border-border bg-muted/30 p-3">
Expand Down Expand Up @@ -278,42 +281,69 @@ export function WorkforcePage() {
<Card>
<CardHeader>
<CardTitle>Allocation Controls</CardTitle>
<p className="text-sm text-muted-foreground">
Distribute your workforce across departments. Higher allocation in each area provides speed bonuses. Total must equal 100%.
</p>
</CardHeader>
<CardContent className="space-y-3">
<form className="grid gap-3 md:grid-cols-2 xl:grid-cols-5" onSubmit={handleAllocationSubmit}>
<Input
value={allocationDraft.operationsPct}
onChange={(event) =>
setAllocationDraft((current) => ({ ...current, operationsPct: event.target.value }))
}
placeholder="Operations %"
inputMode="numeric"
/>
<Input
value={allocationDraft.researchPct}
onChange={(event) =>
setAllocationDraft((current) => ({ ...current, researchPct: event.target.value }))
}
placeholder="Research %"
inputMode="numeric"
/>
<Input
value={allocationDraft.logisticsPct}
onChange={(event) =>
setAllocationDraft((current) => ({ ...current, logisticsPct: event.target.value }))
}
placeholder="Logistics %"
inputMode="numeric"
/>
<Input
value={allocationDraft.corporatePct}
onChange={(event) =>
setAllocationDraft((current) => ({ ...current, corporatePct: event.target.value }))
}
placeholder="Corporate %"
inputMode="numeric"
/>
<Button type="submit" disabled={isSavingAllocation || allocationSum !== 100}>
<div>
<label htmlFor="workforce-ops-pct" className="mb-1 block text-xs text-muted-foreground">
Operations %
</label>
<Input
id="workforce-ops-pct"
value={allocationDraft.operationsPct}
onChange={(event) =>
setAllocationDraft((current) => ({ ...current, operationsPct: event.target.value }))
}
placeholder="e.g., 40"
inputMode="numeric"
/>
</div>
Comment thread
BENZOOgataga marked this conversation as resolved.
<div>
<label htmlFor="workforce-research-pct" className="mb-1 block text-xs text-muted-foreground">
Research %
</label>
<Input
id="workforce-research-pct"
value={allocationDraft.researchPct}
onChange={(event) =>
setAllocationDraft((current) => ({ ...current, researchPct: event.target.value }))
}
placeholder="e.g., 20"
inputMode="numeric"
/>
</div>
<div>
<label htmlFor="workforce-logistics-pct" className="mb-1 block text-xs text-muted-foreground">
Logistics %
</label>
<Input
id="workforce-logistics-pct"
value={allocationDraft.logisticsPct}
onChange={(event) =>
setAllocationDraft((current) => ({ ...current, logisticsPct: event.target.value }))
}
placeholder="e.g., 20"
inputMode="numeric"
/>
</div>
<div>
<label htmlFor="workforce-corporate-pct" className="mb-1 block text-xs text-muted-foreground">
Corporate %
</label>
<Input
id="workforce-corporate-pct"
value={allocationDraft.corporatePct}
onChange={(event) =>
setAllocationDraft((current) => ({ ...current, corporatePct: event.target.value }))
}
placeholder="e.g., 20"
inputMode="numeric"
/>
</div>
<Button type="submit" disabled={isSavingAllocation || allocationSum !== 100} className="self-end">
Save Allocation
</Button>
</form>
Expand Down Expand Up @@ -345,7 +375,7 @@ export function WorkforcePage() {
<Input
value={capacityDeltaInput}
onChange={(event) => setCapacityDeltaInput(event.target.value)}
placeholder="Delta capacity"
placeholder="Enter change (e.g., +50 or -20)"
inputMode="numeric"
/>
<Button type="submit" disabled={isRequestingCapacity}>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/api-client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { isLocalhostHostname, isLocalhostUrl } from "./localhost-utils";

export const HEALTH_POLL_INTERVAL_MS = 3_000;
export const HEALTH_POLL_INTERVAL_MS = 15_000;

const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL?.trim() ?? "";

Expand Down
Loading
Loading