Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
284 changes: 172 additions & 112 deletions cuprum-ui/src/components/operations/OperationHistory.tsx

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions cuprum-ui/src/components/operations/OperationsView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useState } from "react";
import { ProductionSteps } from "./ProductionSteps";
import { OperationHistory } from "./OperationHistory";

/** Operations view: production steps (left) + run history (right). Owns the
* selected-step state that links the two columns (card selection ↔ history
* filter). */
export function OperationsView() {
const [selStep, setSelStep] = useState<string | null>(null);
const toggle = (kind: string) => setSelStep((cur) => (cur === kind ? null : kind));

return (
<div className="flex h-full min-h-0 gap-6 p-6">
<ProductionSteps selStep={selStep} onSelect={toggle} />
<div className="min-w-0 flex-1 border-l border-border pl-6">
<OperationHistory selStep={selStep} onClearStep={() => setSelStep(null)} />
</div>
</div>
);
}
86 changes: 86 additions & 0 deletions cuprum-ui/src/components/operations/ProductionSteps.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type OperationRun } from "@/lib/api";
import { useShell } from "@/shellStore";
import { OPERATION_KINDS, type OpKind } from "@/lib/operationKind";
import { useFlag } from "@/hooks/useFlag";
import { StepCard } from "./StepCard";

export function ProductionSteps({
selStep,
onSelect,
}: {
selStep: string | null;
onSelect: (kind: string) => void;
}) {
const { t } = useTranslation("project");
const showExpose = useFlag("uvExposure");
const showMill = useFlag("cncMilling");
const currentPath = useShell((s) => s.currentPath);
const manifestName = useShell((s) => s.currentManifest?.name ?? "");
const [lastByKind, setLastByKind] = useState<Record<OpKind, OperationRun | null>>({
drill: null,
expose: null,
mill: null,
});

// Last run per op type (one cheap `limit 1` query each); refresh on journal change.
useEffect(() => {
if (!currentPath) {
setLastByKind({ drill: null, expose: null, mill: null });
return;
}
let active = true;
const load = () => {
for (const op of OPERATION_KINDS) {
void api.operationLog
.list(currentPath, 1, 0, op.kind)
.then((rows) => {
if (active) setLastByKind((p) => ({ ...p, [op.kind]: rows[0] ?? null }));
})
.catch(() => {});
}
};
load();
let unlisten: (() => void) | null = null;
void api.operationLog.onChanged(load).then((un) => {
if (active) unlisten = un;
else un();
});
return () => {
active = false;
unlisten?.();
};
}, [currentPath]);

const kinds = OPERATION_KINDS.filter(
(op) => (op.kind !== "expose" || showExpose) && (op.kind !== "mill" || showMill),
);

return (
<div className="flex h-full min-h-0 w-[43%] shrink-0 flex-col">
<div className="flex items-center gap-3 px-1 pb-3">
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
{t("operations.heading")}
</span>
{manifestName && (
<span className="rounded-md bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
{manifestName}
</span>
)}
</div>

<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto pr-1">
{kinds.map((op) => (
<StepCard
key={op.kind}
op={op}
lastRun={lastByKind[op.kind]}
selected={selStep === op.kind}
onSelect={() => onSelect(op.kind)}
/>
))}
</div>
</div>
);
}
98 changes: 98 additions & 0 deletions cuprum-ui/src/components/operations/StepCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { useTranslation } from "react-i18next";
import { Play, Eye, CheckCircle2 } from "lucide-react";
import type { OperationKind } from "@/lib/operationKind";
import type { OperationRun } from "@/lib/api";
import { relativeTime } from "@/i18n/relativeTime";
import { formatDuration } from "@/lib/runHistoryFormat";

export function StepCard({
op,
lastRun,
selected,
onSelect,
}: {
op: OperationKind;
lastRun: OperationRun | null;
selected: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation("project");
const Icon = op.icon;
const ready = op.mode === "ready";

return (
<div
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
className={`cursor-pointer rounded-xl border p-4 transition-colors ${
selected
? "border-primary/60 bg-primary/[0.07]"
: "border-border bg-card/60 hover:border-primary/40"
}`}
>
<div className="flex items-start gap-3.5">
<div className={`grid size-[50px] shrink-0 place-items-center rounded-xl ${op.tile}`}>
<Icon className="size-6" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[17px] font-bold text-foreground">{t(op.titleKey)}</span>
<span
className={`rounded-md px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${
ready ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground"
}`}
>
{t(op.statusKey)}
</span>
</div>
<p className="mt-1 text-[13px] leading-relaxed text-muted-foreground">{t(op.descKey)}</p>
</div>
</div>

<div className="mt-3.5 flex items-center justify-between gap-3 border-t border-border/60 pt-3">
<LastRun run={lastRun} />
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void op.openWindow();
}}
className={`inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3.5 py-2 text-[13px] font-semibold transition-colors ${
ready
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "border border-border bg-muted text-foreground hover:bg-muted/70"
}`}
>
{ready ? <Play className="size-3.5 fill-current" /> : <Eye className="size-3.5" />}
{ready ? t("operations.run") : t("operations.preview")}
</button>
</div>
</div>
);
}

function LastRun({ run }: { run: OperationRun | null }) {
const { t } = useTranslation("project");
if (!run || run.endedAt == null) {
return <span className="text-[11.5px] text-muted-foreground">{t("operations.neverRun")}</span>;
}
const rel = relativeTime(run.startedAt);
const dur = formatDuration(Math.max(0, run.endedAt - run.startedAt), {
h: t("runHistory.hourShort"),
m: t("runHistory.minShort"),
s: t("runHistory.secShort"),
});
return (
<span className="inline-flex items-center gap-1.5 text-[11.5px] text-muted-foreground tabular-nums">
<CheckCircle2 className="size-3.5 text-success" />
{t("operations.lastRun")} · {t(rel.key, rel.params)} · <span className="text-success">{dur}</span>
</span>
);
}
61 changes: 61 additions & 0 deletions cuprum-ui/src/lib/operationKind.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Drill, Sun, Scissors, type LucideIcon } from "lucide-react";
import { api } from "@/lib/api";

/** Op types that appear as production steps and in run history. `expose` is shown
* as "UV" in the design. Mirrors `OperationRun.opType`. */
export type OpKind = "drill" | "expose" | "mill";

export interface OperationKind {
kind: OpKind;
icon: LucideIcon;
/** Tailwind classes for the icon tile (bg + text) — same colour family in step
* cards and history rows. */
tile: string;
mode: "ready" | "preview";
/** i18n keys under the `project` namespace. */
titleKey: string;
descKey: string;
statusKey: string;
/** Open the op's window. (drill/expose resolve to an already-open flag; mill
* resolves void — result is unused, so the return type is widened.) */
openWindow: () => Promise<unknown>;
}

export const OPERATION_KINDS: OperationKind[] = [
{
kind: "drill",
icon: Drill,
tile: "bg-primary/15 text-primary",
mode: "ready",
titleKey: "operations.drill.title",
descKey: "operations.drill.desc",
statusKey: "operations.drill.status",
openWindow: () => api.openDrillWindow(),
},
{
kind: "expose",
icon: Sun,
tile: "bg-amber-400/15 text-amber-400",
mode: "ready",
titleKey: "operations.expose.title",
descKey: "operations.expose.desc",
statusKey: "operations.expose.status",
openWindow: () => api.openExposeWindow(),
},
{
kind: "mill",
icon: Scissors,
tile: "bg-[hsl(18_55%_45%/0.18)] text-[hsl(20_70%_60%)]",
mode: "preview",
titleKey: "operations.mill.title",
descKey: "operations.mill.desc",
statusKey: "operations.mill.status",
openWindow: () => api.openMillWindow(),
},
];

/** Lookup by opType; unknown types (future ops) fall back to the drill family so a
* history row still renders an icon. */
export function operationKind(opType: string): OperationKind {
return OPERATION_KINDS.find((k) => k.kind === opType) ?? OPERATION_KINDS[0];
}
106 changes: 106 additions & 0 deletions cuprum-ui/src/lib/runHistoryFilter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, it, expect } from "vitest";
import {
statusKey,
filterRuns,
statusCounts,
groupByDay,
runMetaLine,
} from "./runHistoryFilter";
import type { OperationRun } from "@/lib/api";

function run(p: Partial<OperationRun>): OperationRun {
return {
runUid: Math.random().toString(36),
projectPath: "/p",
opType: "drill",
startedAt: 1_000_000,
endedAt: 1_000_060,
outcome: "completed",
progressTotal: 84,
progressDone: 84,
paramsJson: "{}",
summaryJson: null,
...p,
};
}

const labels = { holes: "Отв.", tools: "Свёрла", typeLabel: (op: string) => op };

describe("statusKey", () => {
it("maps error and interrupted both to 'interrupted'", () => {
expect(statusKey("error")).toBe("interrupted");
expect(statusKey("interrupted")).toBe("interrupted");
});
it("passes completed/stopped through, null → running", () => {
expect(statusKey("completed")).toBe("completed");
expect(statusKey("stopped")).toBe("stopped");
expect(statusKey(null)).toBe("running");
});
});

describe("filterRuns", () => {
const runs = [
run({ opType: "drill", outcome: "completed" }),
run({ opType: "drill", outcome: "error" }),
run({ opType: "mill", outcome: "stopped" }),
];

it("selStep narrows by opType", () => {
expect(filterRuns({ runs, selStep: "mill", status: "all", query: "", labels }).length).toBe(1);
});
it("status 'interrupted' catches error", () => {
const r = filterRuns({ runs, selStep: null, status: "interrupted", query: "", labels });
expect(r.length).toBe(1);
expect(r[0].outcome).toBe("error");
});
it("query matches the type label", () => {
const r = filterRuns({ runs, selStep: null, status: "all", query: "mill", labels });
expect(r.length).toBe(1);
});
it("query matches meta content (progress count)", () => {
const meta = [run({ progressTotal: 84 }), run({ progressTotal: 12 })];
const r = filterRuns({ runs: meta, selStep: null, status: "all", query: "84", labels });
expect(r.length).toBe(1);
expect(r[0].progressTotal).toBe(84);
});
});

describe("runMetaLine", () => {
const L = { holes: "Отв.", tools: "Свёрла" };
it("joins holes and tool count from progress + params", () => {
const r = run({ progressTotal: 84, paramsJson: JSON.stringify({ toolCount: 3 }) });
expect(runMetaLine(r, L)).toBe("Отв. 84 · Свёрла 3");
});
it("empty when no progress and no toolCount", () => {
const r = run({ progressTotal: null, paramsJson: "{}" });
expect(runMetaLine(r, L)).toBe("");
});
it("swallows malformed params and keeps the holes part", () => {
const r = run({ progressTotal: 12, paramsJson: "{not json" });
expect(runMetaLine(r, L)).toBe("Отв. 12");
});
});

describe("statusCounts", () => {
it("counts per status key over the base set", () => {
const base = [
run({ outcome: "completed" }),
run({ outcome: "completed" }),
run({ outcome: "stopped" }),
run({ outcome: "error" }),
];
expect(statusCounts(base)).toEqual({ all: 4, completed: 2, stopped: 1, interrupted: 1 });
});
});

describe("groupByDay", () => {
it("preserves order and groups consecutive same-day runs", () => {
const now = new Date(2026, 5, 17, 12, 0, 0).getTime() / 1000;
const today = new Date(2026, 5, 17, 9, 0, 0).getTime() / 1000;
const sixAgo = new Date(2026, 5, 11, 9, 0, 0).getTime() / 1000;
const rows = [run({ startedAt: today }), run({ startedAt: sixAgo })];
const groups = groupByDay(rows, now);
expect(groups.map((g) => g.days)).toEqual([0, 6]);
expect(groups[0].runs.length).toBe(1);
});
});
Loading
Loading