From 6260d9122f6affcd8e6a70eb9a18f1b4857d9c02 Mon Sep 17 00:00:00 2001 From: Ivan Chirkin Date: Wed, 17 Jun 2026 02:12:09 +0300 Subject: [PATCH 01/12] feat(operations): operation-kind registry (icon/tile/window per op) --- cuprum-ui/src/lib/operationKind.ts | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 cuprum-ui/src/lib/operationKind.ts diff --git a/cuprum-ui/src/lib/operationKind.ts b/cuprum-ui/src/lib/operationKind.ts new file mode 100644 index 00000000..f47ff658 --- /dev/null +++ b/cuprum-ui/src/lib/operationKind.ts @@ -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; +} + +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]; +} From cb780c83fb0d36642fc1cec40294dac7723894a6 Mon Sep 17 00:00:00 2001 From: Ivan Chirkin Date: Wed, 17 Jun 2026 02:12:50 +0300 Subject: [PATCH 02/12] feat(operations): duration format (hours branch) + calendar day buckets --- cuprum-ui/src/lib/runHistoryFormat.test.ts | 41 ++++++++++++++++++++++ cuprum-ui/src/lib/runHistoryFormat.ts | 35 ++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 cuprum-ui/src/lib/runHistoryFormat.test.ts create mode 100644 cuprum-ui/src/lib/runHistoryFormat.ts diff --git a/cuprum-ui/src/lib/runHistoryFormat.test.ts b/cuprum-ui/src/lib/runHistoryFormat.test.ts new file mode 100644 index 00000000..50f2318f --- /dev/null +++ b/cuprum-ui/src/lib/runHistoryFormat.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { formatDuration, dayBucket } from "./runHistoryFormat"; + +const L = { h: "ч", m: "м", s: "с" }; + +describe("formatDuration", () => { + it("seconds only under a minute", () => { + expect(formatDuration(45, L)).toBe("45 с"); + }); + it("minutes and seconds", () => { + expect(formatDuration(74, L)).toBe("1 м 14 с"); + }); + it("drops zero seconds", () => { + expect(formatDuration(120, L)).toBe("2 м"); + }); + it("hours and minutes at/above an hour (fixes the 9173м bug)", () => { + expect(formatDuration(3 * 3600 + 25 * 60, L)).toBe("3 ч 25 м"); + }); + it("drops zero minutes for whole hours", () => { + expect(formatDuration(2 * 3600, L)).toBe("2 ч"); + }); + it("never renders raw inflated minutes", () => { + expect(formatDuration(5 * 3600 + 2 * 60 + 3, L)).toBe("5 ч 2 м"); + }); +}); + +describe("dayBucket", () => { + const now = new Date(2026, 5, 17, 12, 0, 0).getTime() / 1000; + it("today → n=0", () => { + const ts = new Date(2026, 5, 17, 3, 0, 0).getTime() / 1000; + expect(dayBucket(ts, now)).toEqual({ days: 0 }); + }); + it("calendar yesterday counts as 1 even if <24h", () => { + const ts = new Date(2026, 5, 16, 23, 0, 0).getTime() / 1000; + expect(dayBucket(ts, now)).toEqual({ days: 1 }); + }); + it("six calendar days ago", () => { + const ts = new Date(2026, 5, 11, 8, 0, 0).getTime() / 1000; + expect(dayBucket(ts, now)).toEqual({ days: 6 }); + }); +}); diff --git a/cuprum-ui/src/lib/runHistoryFormat.ts b/cuprum-ui/src/lib/runHistoryFormat.ts new file mode 100644 index 00000000..406461a9 --- /dev/null +++ b/cuprum-ui/src/lib/runHistoryFormat.ts @@ -0,0 +1,35 @@ +/** Short unit labels (resolved from i18n by the caller). */ +export interface DurationLabels { + h: string; + m: string; + s: string; +} + +/** Compact duration from whole seconds: + * - <60s → " с" + * - <3600s → " м с" (drop seconds when 0) + * - ≥3600s → " ч м" (drop minutes when 0) + * The ≥1h branch fixes the old bug that rendered inflated minutes ("9173м 43с"). */ +export function formatDuration(sec: number, L: DurationLabels): string { + const total = Math.max(0, Math.round(sec)); + if (total < 60) return `${total} ${L.s}`; + if (total < 3600) { + const m = Math.floor(total / 60); + const s = total % 60; + return s ? `${m} ${L.m} ${s} ${L.s}` : `${m} ${L.m}`; + } + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + return m ? `${h} ${L.h} ${m} ${L.m}` : `${h} ${L.h}`; +} + +/** Calendar-day distance (local midnight to local midnight). Today=0, yesterday=1. */ +export function dayBucket(tsSec: number, nowSec = Date.now() / 1000): { days: number } { + const midnight = (s: number) => { + const d = new Date(s * 1000); + d.setHours(0, 0, 0, 0); + return d.getTime(); + }; + const days = Math.round((midnight(nowSec) - midnight(tsSec)) / 86_400_000); + return { days: Math.max(0, days) }; +} From 09c6b0cfdea02a17bcbeb8973ebf02a41134d011 Mon Sep 17 00:00:00 2001 From: Ivan Chirkin Date: Wed, 17 Jun 2026 02:14:00 +0300 Subject: [PATCH 03/12] feat(operations): run-history filter/status/count/group logic + tests --- cuprum-ui/src/lib/runHistoryFilter.test.ts | 82 +++++++++++++++++++++ cuprum-ui/src/lib/runHistoryFilter.ts | 84 ++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 cuprum-ui/src/lib/runHistoryFilter.test.ts create mode 100644 cuprum-ui/src/lib/runHistoryFilter.ts diff --git a/cuprum-ui/src/lib/runHistoryFilter.test.ts b/cuprum-ui/src/lib/runHistoryFilter.test.ts new file mode 100644 index 00000000..d8fe18c0 --- /dev/null +++ b/cuprum-ui/src/lib/runHistoryFilter.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { + statusKey, + filterRuns, + statusCounts, + groupByDay, +} from "./runHistoryFilter"; +import type { OperationRun } from "@/lib/api"; + +function run(p: Partial): 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, + }; +} + +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" }), + ]; + const id = (k: string) => k; + + it("selStep narrows by opType", () => { + expect(filterRuns({ runs, selStep: "mill", status: "all", query: "", t: id }).length).toBe(1); + }); + it("status 'interrupted' catches error", () => { + const r = filterRuns({ runs, selStep: null, status: "interrupted", query: "", t: id }); + 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", t: id }); + expect(r.length).toBe(1); + }); +}); + +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); + }); +}); diff --git a/cuprum-ui/src/lib/runHistoryFilter.ts b/cuprum-ui/src/lib/runHistoryFilter.ts new file mode 100644 index 00000000..5f36a1e3 --- /dev/null +++ b/cuprum-ui/src/lib/runHistoryFilter.ts @@ -0,0 +1,84 @@ +import type { OperationRun } from "@/lib/api"; +import { operationKind } from "@/lib/operationKind"; +import { dayBucket } from "@/lib/runHistoryFormat"; + +/** Status filter values (chip ids). `interrupted` folds in `error`. */ +export type StatusFilter = "all" | "completed" | "stopped" | "interrupted"; +/** Per-run status bucket (also drives the badge). */ +export type RunStatus = "completed" | "stopped" | "interrupted" | "running"; + +/** Collapse the DB `outcome` into a display/filter bucket. */ +export function statusKey(outcome: string | null): RunStatus { + if (outcome === "completed") return "completed"; + if (outcome === "stopped") return "stopped"; + if (outcome === "error" || outcome === "interrupted") return "interrupted"; + return "running"; +} + +/** Human-facing label for an op type, via the i18n resolver. */ +function typeLabel(opType: string, t: (k: string) => string): string { + return t(operationKind(opType).titleKey); +} + +/** Short meta line shown under the op name in a history row; also the search + * haystack. Built from cheap run fields (progress + drill tool count). */ +export function runMetaLine(run: OperationRun, t: (k: string) => string): string { + const parts: string[] = []; + if (run.progressTotal != null) parts.push(`${t("runHistory.holesLabel")} ${run.progressTotal}`); + try { + const p = JSON.parse(run.paramsJson) as { toolCount?: number }; + if (p.toolCount != null) parts.push(`${t("runHistory.toolsLabel")} ${p.toolCount}`); + } catch { + /* ignore malformed params */ + } + return parts.join(" · "); +} + +export interface FilterInput { + runs: OperationRun[]; + selStep: string | null; + status: StatusFilter; + query: string; + t: (k: string) => string; +} + +/** base (selStep) → status → query (case-insensitive substring over type label + meta). */ +export function filterRuns({ runs, selStep, status, query, t }: FilterInput): OperationRun[] { + let out = selStep ? runs.filter((r) => r.opType === selStep) : runs; + if (status !== "all") out = out.filter((r) => statusKey(r.outcome) === status); + const q = query.trim().toLowerCase(); + if (q) { + out = out.filter((r) => + `${typeLabel(r.opType, t)} ${runMetaLine(r, t)}`.toLowerCase().includes(q), + ); + } + return out; +} + +/** Counts per chip over a base set (already scoped to selStep). `running` excluded + * from named counts but still in `all`. */ +export function statusCounts(base: OperationRun[]): Record { + const c: Record = { all: base.length, completed: 0, stopped: 0, interrupted: 0 }; + for (const r of base) { + const k = statusKey(r.outcome); + if (k !== "running") c[k] += 1; + } + return c; +} + +export interface DayGroup { + days: number; + runs: OperationRun[]; +} + +/** Group an already-ordered (newest-first) run list into consecutive day buckets. */ +export function groupByDay(runs: OperationRun[], nowSec = Date.now() / 1000): DayGroup[] { + const groups: DayGroup[] = []; + for (const r of runs) { + const { days } = dayBucket(r.startedAt, nowSec); + const last = groups[groups.length - 1]; + if (last && last.days === days) last.runs.push(r); + else groups.push({ days, runs: [r] }); + } + return groups; +} From 09e4ce04d20156a8753d4b55c97c5059fedb1a59 Mon Sep 17 00:00:00 2001 From: Ivan Chirkin Date: Wed, 17 Jun 2026 02:17:24 +0300 Subject: [PATCH 04/12] test(operations): cover runMetaLine and meta-content search --- cuprum-ui/src/lib/runHistoryFilter.test.ts | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cuprum-ui/src/lib/runHistoryFilter.test.ts b/cuprum-ui/src/lib/runHistoryFilter.test.ts index d8fe18c0..904bb1d0 100644 --- a/cuprum-ui/src/lib/runHistoryFilter.test.ts +++ b/cuprum-ui/src/lib/runHistoryFilter.test.ts @@ -4,6 +4,7 @@ import { filterRuns, statusCounts, groupByDay, + runMetaLine, } from "./runHistoryFilter"; import type { OperationRun } from "@/lib/api"; @@ -55,6 +56,28 @@ describe("filterRuns", () => { const r = filterRuns({ runs, selStep: null, status: "all", query: "mill", t: id }); 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", t: id }); + expect(r.length).toBe(1); + expect(r[0].progressTotal).toBe(84); + }); +}); + +describe("runMetaLine", () => { + const id = (k: string) => k; + it("joins holes and tool count from progress + params", () => { + const r = run({ progressTotal: 84, paramsJson: JSON.stringify({ toolCount: 3 }) }); + expect(runMetaLine(r, id)).toBe("runHistory.holesLabel 84 · runHistory.toolsLabel 3"); + }); + it("empty when no progress and no toolCount", () => { + const r = run({ progressTotal: null, paramsJson: "{}" }); + expect(runMetaLine(r, id)).toBe(""); + }); + it("swallows malformed params and keeps the holes part", () => { + const r = run({ progressTotal: 12, paramsJson: "{not json" }); + expect(runMetaLine(r, id)).toBe("runHistory.holesLabel 12"); + }); }); describe("statusCounts", () => { From 414c2b48b32ea46935040a39052ef801679aa355 Mon Sep 17 00:00:00 2001 From: Ivan Chirkin Date: Wed, 17 Jun 2026 02:19:08 +0300 Subject: [PATCH 05/12] feat(operations): i18n keys for step cards + history toolbar/grouping --- cuprum-ui/src/locales/en/project.json | 24 ++++++++++++++++++++++++ cuprum-ui/src/locales/ru/project.json | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/cuprum-ui/src/locales/en/project.json b/cuprum-ui/src/locales/en/project.json index fecac3f9..04a9cb33 100644 --- a/cuprum-ui/src/locales/en/project.json +++ b/cuprum-ui/src/locales/en/project.json @@ -69,6 +69,26 @@ "estimateLabel": "Est.:", "minShort": "m", "secShort": "s", + "hourShort": "h", + "searchPlaceholder": "Search runs", + "filter": { + "completed": "Completed", + "stopped": "Stopped", + "interrupted": "Interrupted" + }, + "day": { + "today": "Today", + "daysAgo_one": "{{count}} day ago", + "daysAgo_few": "{{count}} days ago", + "daysAgo_many": "{{count}} days ago" + }, + "resultCount_one": "{{count}} run", + "resultCount_few": "{{count}} runs", + "resultCount_many": "{{count}} runs", + "notFound": { + "title": "No runs found", + "hint": "Change the filter or search query" + }, "type": { "drill": "Drill", "expose": "Exposure", @@ -243,6 +263,10 @@ "operations": { "heading": "Production steps", "back": "Operations", + "run": "Run", + "preview": "Preview", + "lastRun": "Last run", + "neverRun": "Not run yet", "placeholder": { "title": "Operations", "desc": "The process-step editor lands later." diff --git a/cuprum-ui/src/locales/ru/project.json b/cuprum-ui/src/locales/ru/project.json index 9f89c1e4..1e242d93 100644 --- a/cuprum-ui/src/locales/ru/project.json +++ b/cuprum-ui/src/locales/ru/project.json @@ -69,6 +69,26 @@ "estimateLabel": "Оценка:", "minShort": "м", "secShort": "с", + "hourShort": "ч", + "searchPlaceholder": "Поиск по прогонам", + "filter": { + "completed": "Завершён", + "stopped": "Остановлен", + "interrupted": "Прервано" + }, + "day": { + "today": "Сегодня", + "daysAgo_one": "{{count}} день назад", + "daysAgo_few": "{{count}} дня назад", + "daysAgo_many": "{{count}} дней назад" + }, + "resultCount_one": "{{count}} прогон", + "resultCount_few": "{{count}} прогона", + "resultCount_many": "{{count}} прогонов", + "notFound": { + "title": "Прогонов не найдено", + "hint": "Измените фильтр или поисковый запрос" + }, "type": { "drill": "Сверловка", "expose": "Засветка", @@ -243,6 +263,10 @@ "operations": { "heading": "Производственные шаги", "back": "Операции", + "run": "Запустить", + "preview": "Предпросмотр", + "lastRun": "Последний прогон", + "neverRun": "Ещё не запускалась", "placeholder": { "title": "Операции", "desc": "Редактор производственных шагов появится позже." From 4ef3f58006919a00fe0889960a981e9a17e228b0 Mon Sep 17 00:00:00 2001 From: Ivan Chirkin Date: Wed, 17 Jun 2026 02:22:44 +0300 Subject: [PATCH 06/12] refactor(operations): pass resolved labels into run-history filter (i18n-safe) --- cuprum-ui/src/lib/runHistoryFilter.test.ts | 19 +++++++------- cuprum-ui/src/lib/runHistoryFilter.ts | 29 +++++++++++++--------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/cuprum-ui/src/lib/runHistoryFilter.test.ts b/cuprum-ui/src/lib/runHistoryFilter.test.ts index 904bb1d0..bcd71abf 100644 --- a/cuprum-ui/src/lib/runHistoryFilter.test.ts +++ b/cuprum-ui/src/lib/runHistoryFilter.test.ts @@ -24,6 +24,8 @@ function run(p: Partial): OperationRun { }; } +const labels = { holes: "Отв.", tools: "Свёрла", typeLabel: (op: string) => op }; + describe("statusKey", () => { it("maps error and interrupted both to 'interrupted'", () => { expect(statusKey("error")).toBe("interrupted"); @@ -42,41 +44,40 @@ describe("filterRuns", () => { run({ opType: "drill", outcome: "error" }), run({ opType: "mill", outcome: "stopped" }), ]; - const id = (k: string) => k; it("selStep narrows by opType", () => { - expect(filterRuns({ runs, selStep: "mill", status: "all", query: "", t: id }).length).toBe(1); + 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: "", t: id }); + 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", t: id }); + 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", t: id }); + 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 id = (k: string) => k; + 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, id)).toBe("runHistory.holesLabel 84 · runHistory.toolsLabel 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, id)).toBe(""); + 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, id)).toBe("runHistory.holesLabel 12"); + expect(runMetaLine(r, L)).toBe("Отв. 12"); }); }); diff --git a/cuprum-ui/src/lib/runHistoryFilter.ts b/cuprum-ui/src/lib/runHistoryFilter.ts index 5f36a1e3..ff9f6f2a 100644 --- a/cuprum-ui/src/lib/runHistoryFilter.ts +++ b/cuprum-ui/src/lib/runHistoryFilter.ts @@ -1,5 +1,4 @@ import type { OperationRun } from "@/lib/api"; -import { operationKind } from "@/lib/operationKind"; import { dayBucket } from "@/lib/runHistoryFormat"; /** Status filter values (chip ids). `interrupted` folds in `error`. */ @@ -15,19 +14,25 @@ export function statusKey(outcome: string | null): RunStatus { return "running"; } -/** Human-facing label for an op type, via the i18n resolver. */ -function typeLabel(opType: string, t: (k: string) => string): string { - return t(operationKind(opType).titleKey); +/** Labels resolved by the caller (which owns the i18n namespace). Keeping i18n + * out of this module lets the static i18n checker scope keys correctly. */ +export interface RunLabels { + /** Resolved meta prefix, e.g. t("runHistory.holesLabel"). */ + holes: string; + /** Resolved meta prefix, e.g. t("runHistory.toolsLabel"). */ + tools: string; + /** Resolved op-type display name by opType, e.g. (op) => t(operationKind(op).titleKey). */ + typeLabel: (opType: string) => string; } -/** Short meta line shown under the op name in a history row; also the search - * haystack. Built from cheap run fields (progress + drill tool count). */ -export function runMetaLine(run: OperationRun, t: (k: string) => string): string { +/** Short meta line under the op name in a history row; also the search haystack. + * Built from cheap run fields (progress + drill tool count). */ +export function runMetaLine(run: OperationRun, L: Pick): string { const parts: string[] = []; - if (run.progressTotal != null) parts.push(`${t("runHistory.holesLabel")} ${run.progressTotal}`); + if (run.progressTotal != null) parts.push(`${L.holes} ${run.progressTotal}`); try { const p = JSON.parse(run.paramsJson) as { toolCount?: number }; - if (p.toolCount != null) parts.push(`${t("runHistory.toolsLabel")} ${p.toolCount}`); + if (p.toolCount != null) parts.push(`${L.tools} ${p.toolCount}`); } catch { /* ignore malformed params */ } @@ -39,17 +44,17 @@ export interface FilterInput { selStep: string | null; status: StatusFilter; query: string; - t: (k: string) => string; + labels: RunLabels; } /** base (selStep) → status → query (case-insensitive substring over type label + meta). */ -export function filterRuns({ runs, selStep, status, query, t }: FilterInput): OperationRun[] { +export function filterRuns({ runs, selStep, status, query, labels }: FilterInput): OperationRun[] { let out = selStep ? runs.filter((r) => r.opType === selStep) : runs; if (status !== "all") out = out.filter((r) => statusKey(r.outcome) === status); const q = query.trim().toLowerCase(); if (q) { out = out.filter((r) => - `${typeLabel(r.opType, t)} ${runMetaLine(r, t)}`.toLowerCase().includes(q), + `${labels.typeLabel(r.opType)} ${runMetaLine(r, labels)}`.toLowerCase().includes(q), ); } return out; From 862abf3f020da1c96d18c32b2d764351b42aa286 Mon Sep 17 00:00:00 2001 From: Ivan Chirkin Date: Wed, 17 Jun 2026 02:25:40 +0300 Subject: [PATCH 07/12] =?UTF-8?q?feat(operations):=20left=20column=20?= =?UTF-8?q?=E2=80=94=20StepCard=20+=20ProductionSteps=20with=20per-step=20?= =?UTF-8?q?last=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/operations/ProductionSteps.tsx | 79 +++++++++++++++ .../src/components/operations/StepCard.tsx | 98 +++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 cuprum-ui/src/components/operations/ProductionSteps.tsx create mode 100644 cuprum-ui/src/components/operations/StepCard.tsx diff --git a/cuprum-ui/src/components/operations/ProductionSteps.tsx b/cuprum-ui/src/components/operations/ProductionSteps.tsx new file mode 100644 index 00000000..6f806d43 --- /dev/null +++ b/cuprum-ui/src/components/operations/ProductionSteps.tsx @@ -0,0 +1,79 @@ +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 { StepCard } from "./StepCard"; + +export function ProductionSteps({ + selStep, + onSelect, +}: { + selStep: string | null; + onSelect: (kind: string) => void; +}) { + const { t } = useTranslation("project"); + const currentPath = useShell((s) => s.currentPath); + const manifestName = useShell((s) => s.currentManifest?.name ?? ""); + const [lastByKind, setLastByKind] = useState>({ + 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]); + + return ( +
+
+ + {t("operations.heading")} + + {manifestName && ( + + {manifestName} + + )} +
+ +
+ {OPERATION_KINDS.map((op) => ( + onSelect(op.kind)} + /> + ))} +
+
+ ); +} diff --git a/cuprum-ui/src/components/operations/StepCard.tsx b/cuprum-ui/src/components/operations/StepCard.tsx new file mode 100644 index 00000000..2bd63fd1 --- /dev/null +++ b/cuprum-ui/src/components/operations/StepCard.tsx @@ -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 ( +
{ + 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" + }`} + > +
+
+ +
+
+
+ {t(op.titleKey)} + + {t(op.statusKey)} + +
+

{t(op.descKey)}

+
+
+ +
+ + +
+
+ ); +} + +function LastRun({ run }: { run: OperationRun | null }) { + const { t } = useTranslation("project"); + if (!run || run.endedAt == null) { + return {t("operations.neverRun")}; + } + 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 ( + + + {t("operations.lastRun")} · {t(rel.key, rel.params)} · {dur} + + ); +} From c2fbc391c8251310ec2a999ed57cd69f2c5e840d Mon Sep 17 00:00:00 2001 From: Ivan Chirkin Date: Wed, 17 Jun 2026 02:31:58 +0300 Subject: [PATCH 08/12] fix(operations): gate UV-expose step card behind uvExposure flag --- cuprum-ui/src/components/operations/ProductionSteps.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cuprum-ui/src/components/operations/ProductionSteps.tsx b/cuprum-ui/src/components/operations/ProductionSteps.tsx index 6f806d43..eaacd17e 100644 --- a/cuprum-ui/src/components/operations/ProductionSteps.tsx +++ b/cuprum-ui/src/components/operations/ProductionSteps.tsx @@ -3,6 +3,7 @@ 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({ @@ -13,6 +14,7 @@ export function ProductionSteps({ onSelect: (kind: string) => void; }) { const { t } = useTranslation("project"); + const showExpose = useFlag("uvExposure"); const currentPath = useShell((s) => s.currentPath); const manifestName = useShell((s) => s.currentManifest?.name ?? ""); const [lastByKind, setLastByKind] = useState>({ @@ -50,6 +52,8 @@ export function ProductionSteps({ }; }, [currentPath]); + const kinds = OPERATION_KINDS.filter((op) => op.kind !== "expose" || showExpose); + return (
@@ -64,7 +68,7 @@ export function ProductionSteps({
- {OPERATION_KINDS.map((op) => ( + {kinds.map((op) => ( Date: Wed, 17 Jun 2026 02:37:05 +0300 Subject: [PATCH 09/12] feat(operations): history toolbar, day grouping, status pills, step chip, search --- .../operations/OperationHistory.tsx | 284 +++++++++++------- 1 file changed, 170 insertions(+), 114 deletions(-) diff --git a/cuprum-ui/src/components/operations/OperationHistory.tsx b/cuprum-ui/src/components/operations/OperationHistory.tsx index 41b49f6b..5d7b4205 100644 --- a/cuprum-ui/src/components/operations/OperationHistory.tsx +++ b/cuprum-ui/src/components/operations/OperationHistory.tsx @@ -7,23 +7,27 @@ import { Loader2, History as HistoryIcon, RotateCcw, + Search, } from "lucide-react"; import { api, type OperationRun } from "@/lib/api"; import { useShell } from "@/shellStore"; import { useFlag } from "@/hooks/useFlag"; import { relativeTime } from "@/i18n/relativeTime"; +import { operationKind } from "@/lib/operationKind"; +import { formatDuration } from "@/lib/runHistoryFormat"; +import { + filterRuns, + statusCounts, + groupByDay, + runMetaLine, + statusKey, + type StatusFilter, + type RunLabels, +} from "@/lib/runHistoryFilter"; /** Runs fetched per page; "load more" appends the next page. */ const PAGE_SIZE = 20; -/** Compact duration ("Xm Ys" / "Ys") from whole seconds. */ -function formatDuration(sec: number, minShort: string, secShort: string): string { - if (sec < 60) return `${sec}${secShort}`; - const m = Math.floor(sec / 60); - const s = sec % 60; - return s ? `${m}${minShort} ${s}${secShort}` : `${m}${minShort}`; -} - interface DrillParams { toolCount?: number; feedOverridePct?: number; @@ -60,25 +64,35 @@ async function repeatRun(run: OperationRun) { } } -/** Operation history as a card list — every journalled run across all op types, - * newest first, paginated and filterable. Lives in the Operations view beside the - * operation buttons; a card expands to read-only run details with a "repeat" action. */ -export function OperationHistory() { +const CHIPS: StatusFilter[] = ["all", "completed", "stopped", "interrupted"]; + +/** Operation history as a grouped, searchable, status-filterable run log. Lives in + * the Operations view beside the production steps; selecting a step filters this + * list to that op type. Rows expand to read-only detail with a "repeat" action. */ +export function OperationHistory({ + selStep, + onClearStep, +}: { + selStep: string | null; + onClearStep: () => void; +}) { const { t } = useTranslation("project"); const currentPath = useShell((s) => s.currentPath); // Gate the "repeat" action for expose runs the same way the operation card is // gated, so a past run can't reopen the expose window when the flag is off. const showExpose = useFlag("uvExposure"); const [runs, setRuns] = useState(null); - const [filter, setFilter] = useState("all"); + const [status, setStatus] = useState("all"); + const [query, setQuery] = useState(""); const [expanded, setExpanded] = useState(null); const [hasMore, setHasMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false); - // First page on project change (resets filter/expansion so stale state can't hide - // the new project's runs). + // First page on project change (resets filter/search/expansion so stale state + // can't hide the new project's runs). useEffect(() => { - setFilter("all"); + setStatus("all"); + setQuery(""); setExpanded(null); setHasMore(false); if (!currentPath) { @@ -109,17 +123,13 @@ export function OperationHistory() { loadedCountRef.current = runs?.length ?? 0; }, [runs]); - // Live refresh: a run launched/finished/reconciled in another window (drill / - // expose / mill) broadcasts `operation-runs://changed`. Refetch the loaded - // window so a new run appears and "Идёт" flips to its outcome without reopening - // the project. StrictMode-safe listener lifecycle (mirrors useBridgeListeners). + // Live refresh: a run launched/finished/reconciled in another window broadcasts + // `operation-runs://changed`. Refetch the loaded window so a new run appears and + // "Идёт" flips to its outcome. StrictMode-safe listener lifecycle. useEffect(() => { if (!currentPath) return; let active = true; let unlisten: (() => void) | null = null; - // Per-fetch generation: a start→finish pair emits two events back-to-back, so - // two refetches can be in flight at once. Drop a late-resolving older response - // so a stale "Идёт" can't overwrite the fresh outcome. let fetchGen = 0; void api.operationLog .onChanged(() => { @@ -158,43 +168,91 @@ export function OperationHistory() { .finally(() => setLoadingMore(false)); }; - const types = useMemo(() => [...new Set((runs ?? []).map((r) => r.opType))], [runs]); - const shown = useMemo( - () => (filter === "all" ? (runs ?? []) : (runs ?? []).filter((r) => r.opType === filter)), - [runs, filter], + const labels: RunLabels = useMemo( + () => ({ + holes: t("runHistory.holesLabel"), + tools: t("runHistory.toolsLabel"), + typeLabel: (op: string) => t(operationKind(op).titleKey), + }), + [t], + ); + const durLabels = useMemo( + () => ({ + h: t("runHistory.hourShort"), + m: t("runHistory.minShort"), + s: t("runHistory.secShort"), + }), + [t], + ); + + const base = useMemo( + () => (selStep ? (runs ?? []).filter((r) => r.opType === selStep) : (runs ?? [])), + [runs, selStep], + ); + const counts = useMemo(() => statusCounts(base), [base]); + const filtered = useMemo( + () => filterRuns({ runs: runs ?? [], selStep, status, query, labels }), + [runs, selStep, status, query, labels], ); + const groups = useMemo(() => groupByDay(filtered), [filtered]); - const typeLabel = (op: string) => t([`runHistory.type.${op}`, "runHistory.type.unknown"], { op }); + const dayLabel = (days: number) => + days === 0 ? t("runHistory.day.today") : t("runHistory.day.daysAgo", { count: days }); return (
- {/* Header + filter chips */} -
-
+ {/* Header */} +
+
{t("runHistory.title")}
- {types.length > 1 && ( -
- {["all", ...types].map((op) => ( - - ))} -
+ {selStep && ( + )} + + {t("runHistory.resultCount", { count: filtered.length })} +
- {/* Card list */} + {/* Toolbar */} +
+
+ + setQuery(e.target.value)} + placeholder={t("runHistory.searchPlaceholder")} + className="w-full rounded-lg border border-border bg-card/70 py-2 pl-8 pr-3 text-[12.5px] text-foreground placeholder:text-muted-foreground/70 focus:border-primary/50 focus:outline-none" + /> +
+
+ {CHIPS.map((c) => ( + + ))} +
+
+ + {/* List */} {runs === null ? (
@@ -203,29 +261,42 @@ export function OperationHistory() {
{t("runHistory.noProject")}
- ) : shown.length === 0 ? ( -
- {t("runHistory.empty")} + ) : filtered.length === 0 ? ( +
+ +
{t("runHistory.notFound.title")}
+
{t("runHistory.notFound.hint")}
) : ( -
- {shown.map((r) => ( - setExpanded((cur) => (cur === r.runUid ? null : r.runUid))} - /> +
+ {groups.map((g) => ( +
+
+ + {dayLabel(g.days)} + + +
+ {g.runs.map((r) => ( + setExpanded((cur) => (cur === r.runUid ? null : r.runUid))} + /> + ))} +
))} {hasMore && ( - {/* Read-only detail */} {expanded && ( -
+
{run.progressTotal != null && ( 0 && ( )}
@@ -347,48 +414,37 @@ function Row({ label, value }: { label: string; value: string }) { ); } -function OutcomeBadge({ +function StatusPill({ outcome, t, }: { outcome: string | null; t: (k: string) => string; }) { - if (outcome === "completed") { + const k = statusKey(outcome); + if (k === "completed") return ( - + {t("runHistory.outcome.completed")} ); - } - if (outcome === "stopped") { + if (k === "stopped") return ( - + {t("runHistory.outcome.stopped")} ); - } - if (outcome === "error") { + if (k === "interrupted") return ( - - - {t("runHistory.outcome.error")} - - ); - } - if (outcome === "interrupted") { - // Orphaned run: its window closed mid-run, reconciled at the next project open. - return ( - + {t("runHistory.outcome.interrupted")} ); - } return ( - + {t("runHistory.outcome.running")} From 85d0500f4983c58f70919b3a2f9de8a844d75eef Mon Sep 17 00:00:00 2001 From: Ivan Chirkin Date: Wed, 17 Jun 2026 02:44:33 +0300 Subject: [PATCH 10/12] fix(operations): distinguish no-runs-yet from no-search-results empty state --- cuprum-ui/src/components/operations/OperationHistory.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cuprum-ui/src/components/operations/OperationHistory.tsx b/cuprum-ui/src/components/operations/OperationHistory.tsx index 5d7b4205..9eb371fb 100644 --- a/cuprum-ui/src/components/operations/OperationHistory.tsx +++ b/cuprum-ui/src/components/operations/OperationHistory.tsx @@ -261,6 +261,10 @@ export function OperationHistory({
{t("runHistory.noProject")}
+ ) : (runs?.length ?? 0) === 0 ? ( +
+ {t("runHistory.empty")} +
) : filtered.length === 0 ? (
From b911434c4c8ff6254170e757f5e8f31f14fac20e Mon Sep 17 00:00:00 2001 From: Ivan Chirkin Date: Wed, 17 Jun 2026 02:48:31 +0300 Subject: [PATCH 11/12] feat(operations): OperationsView wires steps to history; gate mill card; drop inline block --- .../components/operations/OperationsView.tsx | 20 +++ .../components/operations/ProductionSteps.tsx | 5 +- cuprum-ui/src/pages/ProjectPage.tsx | 117 +----------------- 3 files changed, 27 insertions(+), 115 deletions(-) create mode 100644 cuprum-ui/src/components/operations/OperationsView.tsx diff --git a/cuprum-ui/src/components/operations/OperationsView.tsx b/cuprum-ui/src/components/operations/OperationsView.tsx new file mode 100644 index 00000000..131ac7ac --- /dev/null +++ b/cuprum-ui/src/components/operations/OperationsView.tsx @@ -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(null); + const toggle = (kind: string) => setSelStep((cur) => (cur === kind ? null : kind)); + + return ( +
+ +
+ setSelStep(null)} /> +
+
+ ); +} diff --git a/cuprum-ui/src/components/operations/ProductionSteps.tsx b/cuprum-ui/src/components/operations/ProductionSteps.tsx index eaacd17e..324fc39a 100644 --- a/cuprum-ui/src/components/operations/ProductionSteps.tsx +++ b/cuprum-ui/src/components/operations/ProductionSteps.tsx @@ -15,6 +15,7 @@ export function ProductionSteps({ }) { 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>({ @@ -52,7 +53,9 @@ export function ProductionSteps({ }; }, [currentPath]); - const kinds = OPERATION_KINDS.filter((op) => op.kind !== "expose" || showExpose); + const kinds = OPERATION_KINDS.filter( + (op) => (op.kind !== "expose" || showExpose) && (op.kind !== "mill" || showMill), + ); return (
diff --git a/cuprum-ui/src/pages/ProjectPage.tsx b/cuprum-ui/src/pages/ProjectPage.tsx index 27baf7e6..9656dddf 100644 --- a/cuprum-ui/src/pages/ProjectPage.tsx +++ b/cuprum-ui/src/pages/ProjectPage.tsx @@ -1,11 +1,10 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { LayoutGrid, Layers, ListChecks, Settings, Undo2, Redo2, Save, History, Loader2, Drill, ArrowRight, Scissors, Sun } from "lucide-react"; +import { LayoutGrid, Layers, ListChecks, Settings, Undo2, Redo2, Save, History, Loader2 } from "lucide-react"; import { DesignsGallery } from "@/components/project/DesignsGallery"; import { PanelEditor } from "@/components/project/PanelEditor"; -import { OperationHistory } from "@/components/operations/OperationHistory"; +import { OperationsView } from "@/components/operations/OperationsView"; import { ProjectSettingsModal } from "@/components/project/ProjectSettingsModal"; -import { api } from "@/lib/api"; import { useShell } from "@/shellStore"; import { useArtifacts } from "@/artifactsStore"; import { useHistory } from "@/historyStore"; @@ -13,8 +12,6 @@ import { relativeTime } from "@/i18n/relativeTime"; import { overallProgress } from "@/lib/artifactProgress"; import { ProgressRing } from "@/components/ui/ProgressRing"; import { NavTabs, type NavTab } from "@/components/ui/NavTabs"; -import { useFlag } from "@/hooks/useFlag"; - type ProjectTab = "panel" | "designs" | "operations"; export function ProjectPage() { @@ -22,9 +19,6 @@ export function ProjectPage() { const manifest = useShell((s) => s.currentManifest); const [tab, setTab] = useState("panel"); const [settingsOpen, setSettingsOpen] = useState(false); - const showExpose = useFlag("uvExposure"); - const showMill = useFlag("cncMilling"); - const workingDir = useShell((s) => s.workingDir); const undo = useHistory((s) => s.undo); const redo = useHistory((s) => s.redo); @@ -36,7 +30,6 @@ export function ProjectPage() { const historyBusy = useHistory((s) => s.historyBusy); const saving = useShell((s) => s.saving); const [pointsOpen, setPointsOpen] = useState(false); - // Which operation card is open inline (null = list view). const artifactProgress = useArtifacts((s) => s.artifactProgress); const pruneArtifactProgress = useArtifacts((s) => s.pruneArtifactProgress); @@ -188,111 +181,7 @@ export function ProjectPage() {
{tab === "panel" && } {tab === "designs" && } - {tab === "operations" && ( - /* Operations view: operation buttons (left) + run history (right). */ -
- {/* Section heading */} -
- - {t("operations.heading")} - - {manifest.name && ( - - {manifest.name} - - )} -
- -
- {/* Operation cards (left) */} -
- {/* Drill card — active */} - - - {/* Expose card — gated behind the uvExposure flag (unfinished) */} - {showExpose && ( - - )} - - {/* Milling card — gated behind the cncMilling flag (unfinished) */} - {showMill && ( - - )} -
- - {/* Run history (right) */} -
- -
-
-
- )} + {tab === "operations" && }
setSettingsOpen(false)} /> From c58634de48576500848ee1802fbef180ecf6cd10 Mon Sep 17 00:00:00 2001 From: Ivan Chirkin Date: Wed, 17 Jun 2026 02:54:40 +0300 Subject: [PATCH 12/12] docs(design): journal Operations screen redesign --- docs/DESIGN.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 62f3126c..62e9290f 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -629,5 +629,27 @@ cuprum-ui/src/ Shift+колесо → горизонтальный пан, которого нет у общего `onWheel` (вынос изменил бы поведение панели/сверловки). -*Ревизия 2026-06-10. Обновлять при изменении дизайн-системы и принятии новых +- **2026-06-17 — редизайн экрана «Операции» (PR #701, эпик #697).** Две колонки: + слева **производственные шаги** карточками (`StepCard`), справа **история прогонов** + (`OperationHistory`). Карточка шага: иконка-плитка `size-[50px]` с цветом по типу + операции (drill — медный `bg-primary/15`, UV — жёлтый `bg-amber-400/15`, фрезеровка — + терракот `bg-[hsl(18_55%_45%/0.18)]`), заголовок 17px + бейдж статуса, описание, + строка «последний прогон» (зелёный чек), кнопка **Запустить** (медная заливка) / + **Предпросмотр** (muted). Выбор шага (accent-рамка + `bg-primary/[0.07]`) фильтрует + историю. История: тулбар (поиск + чипы-фильтры статуса со счётчиками, активный — медная + заливка), группировка по дням со sticky-заголовками (`bg-background`, чтобы маскировать + прокрутку), **статус-пилюли по семантике** — Завершён `text-success`, Остановлен + `text-warning`, Прервано `text-destructive` (объединяет `error`+`interrupted`), Идёт + muted; empty-state (нет прогонов vs ничего не найдено). Справочник типа операции — + единый `lib/operationKind.ts` (иконка/плитка/окно), делится карточкой и строкой истории. + Чистая логика (формат длительности с веткой ≥1ч, группировка по дням, фильтр/счётчики) — + в тестируемых `lib/runHistoryFormat.ts` / `lib/runHistoryFilter.ts`. **i18n из чистых + `.ts`-модулей не зовём** — литеральные ключи в lib ломают статический i18n-check + (он не видит namespace без `useTranslation`); вместо этого компонент резолвит метки и + передаёт их в lib строками. Зона вынесена из `ProjectPage` в `OperationsView` + (владеет `selStep`). Гейтинг карточек по флагам сохранён (UV — `uvExposure`, + фрезеровка — `cncMilling`). Live-баннер прогона и строка параметров операции — отдельными + задачами (#699/#700). + +*Ревизия 2026-06-17. Обновлять при изменении дизайн-системы и принятии новых дизайн-решений (добавляй запись в лог выше).*