1
0
Fork 0
polaris-task-force/src/components/frontend/baseManagement/ConstructionPanel.tsx
Z8MB1E 5be390012a feat(base): timestamp-based construction lifecycle with progress panel
Blueprints declare constructionDurationMinutes instead of tick counts. Delivering the last material starts construction at delivery time via maybeStartConstruction, setting constructionStartedAt/CompletesAt on the site; the game tick only sweeps completed sites. Structure detail page extracts server data helpers and shows a ConstructionPanel with a literal ETA countdown.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-16 23:14:07 -04:00

131 lines
4.4 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { ConstructionEta } from "@/components/frontend/baseManagement/ConstructionEta";
interface ConstructionMaterial {
name: string;
required: number;
delivered: number;
}
interface ConstructionPanelProps {
status: "awaiting_materials" | "building" | "complete" | null;
startedAt: string | null;
completesAt: string | null;
durationMinutes: number | null;
materials: ConstructionMaterial[];
}
const STATUS_LABELS: Record<string, { label: string; className: string }> = {
awaiting_materials: {
label: "Awaiting Materials",
className: "border-amber-500/40 bg-amber-500/10 text-amber-300",
},
building: {
label: "Building",
className: "border-blue-500/40 bg-blue-500/10 text-blue-300",
},
complete: {
label: "Operational",
className: "border-emerald-500/40 bg-emerald-500/10 text-emerald-300",
},
};
function progressPct(startedAt: string | null, completesAt: string | null, nowMs: number): number {
if (!startedAt || !completesAt) return 0;
const start = new Date(startedAt).getTime();
const end = new Date(completesAt).getTime();
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return 0;
const ratio = (nowMs - start) / (end - start);
if (!Number.isFinite(ratio)) return 0;
return Math.min(100, Math.max(0, Math.round(ratio * 100)));
}
export function ConstructionPanel({
status,
startedAt,
completesAt,
durationMinutes,
materials,
}: ConstructionPanelProps) {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const timer = setInterval(() => setNowMs(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
if (!status || status === "complete") return null;
const statusInfo = STATUS_LABELS[status] ?? STATUS_LABELS.awaiting_materials;
const allDelivered = materials.every((material) => material.delivered >= material.required);
const pct = status === "building" ? progressPct(startedAt, completesAt, nowMs) : 0;
return (
<div className="rounded-lg border border-border p-5 flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold">Construction</h2>
<span
className={`rounded-sm border px-2 py-0.5 text-xs uppercase tracking-wide ${statusInfo.className}`}
>
{statusInfo.label}
</span>
</div>
{status === "building" && (
<div className="flex flex-col gap-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>
Build time {durationMinutes != null ? `${durationMinutes.toLocaleString()} min` : ""}
</span>
<span>{pct}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-blue-400"
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex justify-end text-xs tabular-nums text-muted-foreground">
<ConstructionEta completesAt={completesAt} />
</div>
</div>
)}
{status === "awaiting_materials" && (
<p className="text-xs text-muted-foreground">
Ship the required materials to this site. Construction starts automatically once
everything has arrived.
</p>
)}
<div className="flex flex-col gap-1.5">
{materials.map((material) => {
const met = material.delivered >= material.required;
return (
<div
key={material.name}
className="flex items-center justify-between gap-2 rounded-md bg-muted/40 px-3 py-1.5 text-sm"
>
<span>{material.name}</span>
<span className={`tabular-nums ${met ? "text-emerald-400" : "text-amber-300"}`}>
{material.delivered.toLocaleString()} / {material.required.toLocaleString()}
</span>
</div>
);
})}
{materials.length === 0 && (
<p className="text-xs text-muted-foreground">No materials required by this blueprint.</p>
)}
</div>
{status === "awaiting_materials" && !allDelivered && (
<p className="text-xs text-muted-foreground">
{materials.filter((material) => material.delivered < material.required).length} material
type(s) still missing.
</p>
)}
</div>
);
}