Skip to content

Commit 075d1fb

Browse files
committed
feat(block): copy and paste goals
Append copied goal intent without replacing the destination primary goal or importing recipe configuration. Preserve rates and stock targets, skip duplicates, and keep paste undoable.\n\nCloses #151
1 parent e9854aa commit 075d1fb

8 files changed

Lines changed: 353 additions & 7 deletions

File tree

app/e2e/mut/goal-clipboard.e2e.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { DatabaseSync } from "node:sqlite";
2+
import { expect, type Page, test } from "@playwright/test";
3+
import { activeProjectDbFile, expectUndoTop, goto, toast, uniqueName } from "./helpers";
4+
5+
async function dismissDataDriftPrompt(page: Page) {
6+
const ignore = page.getByRole("button", { name: "Ignore for now" });
7+
try {
8+
await expect(ignore).toBeVisible({ timeout: 2_000 });
9+
await ignore.click();
10+
} catch {
11+
// Most runs have no reference-data drift prompt.
12+
}
13+
}
14+
15+
test("goals copy between blocks without replacing existing goals or recipes", async ({
16+
page,
17+
context,
18+
}) => {
19+
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
20+
const sourceData = {
21+
goals: [
22+
{ name: "iron-plate", rate: 2, unit: "min" },
23+
{ name: "copper-plate", rate: 20 / 3600, stock: 20, window: 3600 },
24+
],
25+
recipes: ["iron-plate"],
26+
};
27+
const destinationData = {
28+
goals: [
29+
{ name: "stone-brick", rate: 1 },
30+
{ name: "iron-plate", rate: 99 },
31+
],
32+
recipes: [],
33+
};
34+
const db = new DatabaseSync(activeProjectDbFile());
35+
let sourceId: number;
36+
let destinationId: number;
37+
try {
38+
db.exec("PRAGMA busy_timeout = 5000");
39+
const insert = db.prepare("INSERT INTO blocks (name, data) VALUES (?, ?)");
40+
sourceId = Number(
41+
insert.run(uniqueName("Goal clipboard source"), JSON.stringify(sourceData)).lastInsertRowid,
42+
);
43+
destinationId = Number(
44+
insert.run(uniqueName("Goal clipboard destination"), JSON.stringify(destinationData))
45+
.lastInsertRowid,
46+
);
47+
} finally {
48+
db.close();
49+
}
50+
51+
await goto(page, `/block/${sourceId}`);
52+
await dismissDataDriftPrompt(page);
53+
await page.getByRole("button", { name: "Copy goals" }).click();
54+
await expect(toast(page, "Copied 2 goals.")).toBeVisible();
55+
56+
await goto(page, `/block/${destinationId}`);
57+
await dismissDataDriftPrompt(page);
58+
await page.getByRole("button", { name: "Paste goals" }).click();
59+
await expect(toast(page, "Pasted 1 goal; skipped 1 already present.")).toBeVisible();
60+
await expect(page.locator("[data-goal]")).toHaveCount(3);
61+
await expect(page.locator("[data-goal]").nth(0)).toHaveAttribute("data-goal", "stone-brick");
62+
await expect(page.locator("[data-goal]").nth(1)).toHaveAttribute("data-goal", "iron-plate");
63+
await expect(page.locator("[data-goal]").nth(2)).toHaveAttribute("data-goal", "copper-plate");
64+
await expectUndoTop(page, /Undo: Paste 1 goal/);
65+
66+
const saved = new DatabaseSync(activeProjectDbFile(), { readOnly: true });
67+
try {
68+
const row = saved.prepare("SELECT data FROM blocks WHERE id = ?").get(destinationId) as {
69+
data: string;
70+
};
71+
const parsed = JSON.parse(row.data) as typeof destinationData;
72+
expect(parsed.goals).toEqual([
73+
{ name: "stone-brick", rate: 1 },
74+
{ name: "iron-plate", rate: 99 },
75+
{ name: "copper-plate", rate: 20 / 3600, stock: 20, window: 3600 },
76+
]);
77+
expect(parsed.recipes).toEqual([]);
78+
} finally {
79+
saved.close();
80+
}
81+
});

app/src/components/block/doc-store.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,35 @@ describe("goals", () => {
7979
expect(doc.store.state.dirty).toBe(true);
8080
});
8181

82+
it("appendGoals preserves destination order, skips duplicates, and drops derived factory rates", () => {
83+
const doc = seeded();
84+
const result = doc.appendGoals([
85+
{ name: "iron-plate", rate: 99 },
86+
{ name: "steel-plate", rate: 3, unit: "min", factoryRate: 4 },
87+
{ name: "steel-plate", rate: 7 },
88+
{ name: "stone-brick", rate: 20 / 3600, stock: 20, window: 3600 },
89+
]);
90+
91+
expect(result).toEqual({ added: 2, skipped: 2 });
92+
expect(doc.store.state.goals).toEqual([
93+
{ name: "iron-plate", rate: 2 },
94+
{ name: "copper-plate", rate: 0.5 },
95+
{ name: "steel-plate", rate: 3, unit: "min" },
96+
{ name: "stone-brick", rate: 20 / 3600, stock: 20, window: 3600 },
97+
]);
98+
expect(doc.store.state.dirty).toBe(true);
99+
});
100+
101+
it("appendGoals leaves the document clean when every copied goal already exists", () => {
102+
const doc = seeded();
103+
expect(doc.appendGoals([{ name: "iron-plate", rate: 99 }])).toEqual({
104+
added: 0,
105+
skipped: 1,
106+
});
107+
expect(doc.store.state.goals[0]).toEqual({ name: "iron-plate", rate: 2 });
108+
expect(doc.store.state.dirty).toBe(false);
109+
});
110+
82111
it("stock goals derive rate = stock / window and convert back losslessly", () => {
83112
const doc = seeded();
84113
doc.makeStockGoal("iron-plate"); // rate 2/s × default window

app/src/components/block/doc-store.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,21 @@ export function createBlockDocStore() {
296296
/** Persist the complete visual goal order after a drag. The first entry keeps
297297
* its existing semantic role as the block's primary naming/scaling anchor. */
298298
reorderGoals: (goals: Goal[]) => edit(() => ({ goals })),
299+
/** Append complete copied goal definitions without replacing destination
300+
* goals. Existing goods (and repeated goods in the payload) are skipped, so
301+
* the destination's first goal remains its naming/scaling anchor. */
302+
appendGoals: (goals: readonly Goal[]): { added: number; skipped: number } => {
303+
const names = new Set(store.state.goals.map((goal) => goal.name));
304+
const additions: Goal[] = [];
305+
for (const input of goals) {
306+
if (names.has(input.name)) continue;
307+
names.add(input.name);
308+
const { factoryRate: _factoryRate, ...goal } = input;
309+
additions.push(goal);
310+
}
311+
if (additions.length) edit((s) => ({ goals: [...s.goals, ...additions] }));
312+
return { added: additions.length, skipped: goals.length - additions.length };
313+
},
299314

300315
/* ── block face ── */
301316
// Block icon (#40): an explicit item/fluid, or null = follow the first goal.

app/src/components/block/goal-card.tsx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type { BlockDocStore } from "./doc-store.ts";
2828
import type { LogiView, SolveResult } from "./solve-view.ts";
2929
import { SortableGoal } from "./sortable-goal.tsx";
3030
import { SupplyPriorityControl } from "./supply-priority-control.tsx";
31+
import { GoalClipboardActions } from "./goal-clipboard-actions.tsx";
3132

3233
/** The Goal card: goals as compact stacked cells (icon over rate) so many fit —
3334
* a block can target several products at once. Each goal has a target rate (a
@@ -75,13 +76,24 @@ export function GoalCard({
7576
<Card>
7677
<CardHeader className="justify-between">
7778
<CardTitle>Goal</CardTitle>
78-
<SupplyPriorityControl
79-
value={supplyPriority}
80-
onChange={(priority) => {
81-
doc.setSupplyPriority(priority);
82-
doc.note("Set block supply priority");
83-
}}
84-
/>
79+
<div className="flex items-center gap-1">
80+
<GoalClipboardActions
81+
goals={goals}
82+
onPaste={(copied) => {
83+
const result = doc.appendGoals(copied);
84+
if (result.added)
85+
doc.note(`Paste ${result.added} goal${result.added === 1 ? "" : "s"}`);
86+
return result;
87+
}}
88+
/>
89+
<SupplyPriorityControl
90+
value={supplyPriority}
91+
onChange={(priority) => {
92+
doc.setSupplyPriority(priority);
93+
doc.note("Set block supply priority");
94+
}}
95+
/>
96+
</div>
8597
</CardHeader>
8698
<CardContent className="space-y-2">
8799
<DndContext
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { ClipboardPaste, Copy } from "lucide-react";
2+
import { Button } from "#/components/ui/button.tsx";
3+
import { Tooltip } from "#/components/ui/tooltip.tsx";
4+
import { parseGoalsClipboard, serializeGoalsClipboard } from "#/lib/goal-clipboard.ts";
5+
import { toast } from "#/lib/toast-store.ts";
6+
import type { Goal } from "../../db/schema.ts";
7+
8+
/** Copy/paste controls for transferring goal intent without any recipe or block
9+
* configuration. The caller owns the append operation so it remains a normal
10+
* doc-store edit with the editor's existing save and undo behavior. */
11+
export function GoalClipboardActions({
12+
goals,
13+
onPaste,
14+
}: {
15+
goals: readonly Goal[];
16+
onPaste: (goals: readonly Goal[]) => { added: number; skipped: number };
17+
}) {
18+
const copyGoals = async () => {
19+
if (!navigator.clipboard?.writeText) {
20+
toast({ message: "Clipboard access is unavailable.", tone: "destructive" });
21+
return;
22+
}
23+
try {
24+
await navigator.clipboard.writeText(serializeGoalsClipboard(goals));
25+
toast({
26+
message: `Copied ${goals.length} goal${goals.length === 1 ? "" : "s"}.`,
27+
tone: "success",
28+
});
29+
} catch {
30+
toast({ message: "Could not copy goals to the clipboard.", tone: "destructive" });
31+
}
32+
};
33+
const pasteGoals = async () => {
34+
if (!navigator.clipboard?.readText) {
35+
toast({ message: "Clipboard access is unavailable.", tone: "destructive" });
36+
return;
37+
}
38+
let text: string;
39+
try {
40+
text = await navigator.clipboard.readText();
41+
} catch {
42+
toast({ message: "Could not read goals from the clipboard.", tone: "destructive" });
43+
return;
44+
}
45+
const copied = parseGoalsClipboard(text);
46+
if (!copied) {
47+
toast({ message: "Clipboard does not contain PyOps goals.", tone: "destructive" });
48+
return;
49+
}
50+
if (!copied.length) {
51+
toast({ message: "The copied goal list is empty." });
52+
return;
53+
}
54+
const { added, skipped } = onPaste(copied);
55+
if (!added) {
56+
toast({ message: `No goals pasted — ${skipped} already present.` });
57+
return;
58+
}
59+
toast({
60+
message: `Pasted ${added} goal${added === 1 ? "" : "s"}${skipped ? `; skipped ${skipped} already present` : ""}.`,
61+
tone: "success",
62+
});
63+
};
64+
65+
return (
66+
<>
67+
<Tooltip content="Copy all goals — includes rates, stock targets, refill windows, and order">
68+
<Button
69+
variant="ghost"
70+
size="icon-xs"
71+
aria-label="Copy goals"
72+
disabled={!goals.length}
73+
onClick={() => void copyGoals()}
74+
className="text-muted-foreground"
75+
>
76+
<Copy className="size-4" />
77+
</Button>
78+
</Tooltip>
79+
<Tooltip content="Paste goals — append copied goals without changing existing goals or recipes">
80+
<Button
81+
variant="ghost"
82+
size="icon-xs"
83+
aria-label="Paste goals"
84+
onClick={() => void pasteGoals()}
85+
className="text-muted-foreground"
86+
>
87+
<ClipboardPaste className="size-4" />
88+
</Button>
89+
</Tooltip>
90+
</>
91+
);
92+
}

app/src/lib/goal-clipboard.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
import { parseGoalsClipboard, serializeGoalsClipboard } from "./goal-clipboard.ts";
3+
4+
describe("goal clipboard", () => {
5+
it("round-trips goal intent while dropping source-derived factory rates", () => {
6+
const text = serializeGoalsClipboard([
7+
{ name: "iron-plate", rate: 2, unit: "min", factoryRate: 8 },
8+
{ name: "stone-brick", rate: 8, stock: 20, window: 3600, factoryRate: 8 },
9+
]);
10+
11+
expect(parseGoalsClipboard(text)).toEqual([
12+
{ name: "iron-plate", rate: 2, unit: "min" },
13+
{ name: "stone-brick", rate: 20 / 3600, stock: 20, window: 3600 },
14+
]);
15+
});
16+
17+
it("accepts goals from the existing Copy setup JSON", () => {
18+
expect(
19+
parseGoalsClipboard(JSON.stringify({ goals: [{ name: "copper-plate", rate: 0.5 }] })),
20+
).toEqual([{ name: "copper-plate", rate: 0.5 }]);
21+
});
22+
23+
it("rejects malformed, unrelated, and unknown-version payloads", () => {
24+
expect(parseGoalsClipboard("not json")).toBeNull();
25+
expect(parseGoalsClipboard(JSON.stringify({ recipes: ["iron-plate"] }))).toBeNull();
26+
expect(
27+
parseGoalsClipboard(
28+
JSON.stringify({ kind: "pyops/goals", version: 2, goals: [{ name: "a", rate: 1 }] }),
29+
),
30+
).toBeNull();
31+
expect(
32+
parseGoalsClipboard(JSON.stringify({ goals: [{ name: "iron-plate", rate: "fast" }] })),
33+
).toBeNull();
34+
});
35+
});

app/src/lib/goal-clipboard.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { Goal } from "../db/schema.ts";
2+
import { STOCK_WINDOW_DEFAULT } from "./goals.ts";
3+
4+
const GOAL_CLIPBOARD_KIND = "pyops/goals";
5+
const GOAL_CLIPBOARD_VERSION = 1;
6+
7+
type JsonRecord = Record<string, unknown>;
8+
9+
const isRecord = (value: unknown): value is JsonRecord =>
10+
typeof value === "object" && value !== null && !Array.isArray(value);
11+
12+
const optionalFinite = (value: unknown): value is number | undefined =>
13+
value === undefined || (typeof value === "number" && Number.isFinite(value));
14+
15+
/** Copy the user's goal intent, excluding factoryRate because it is derived from
16+
* the source block's factory context and must be recomputed for the destination. */
17+
function clipboardGoal(value: unknown): Goal | null {
18+
if (!isRecord(value) || typeof value.name !== "string" || !value.name.trim()) return null;
19+
if (typeof value.rate !== "number" || !Number.isFinite(value.rate)) return null;
20+
if (
21+
value.direction !== undefined &&
22+
value.direction !== "produce" &&
23+
value.direction !== "consume"
24+
)
25+
return null;
26+
if (value.unit !== undefined && value.unit !== "s" && value.unit !== "min" && value.unit !== "h")
27+
return null;
28+
if (!optionalFinite(value.stock) || (value.stock !== undefined && value.stock <= 0)) return null;
29+
if (!optionalFinite(value.window) || (value.window !== undefined && value.window <= 0))
30+
return null;
31+
32+
return {
33+
name: value.name.trim(),
34+
rate:
35+
value.stock !== undefined ? value.stock / (value.window ?? STOCK_WINDOW_DEFAULT) : value.rate,
36+
...(value.direction ? { direction: value.direction } : {}),
37+
...(value.unit ? { unit: value.unit } : {}),
38+
...(value.stock !== undefined ? { stock: value.stock } : {}),
39+
...(value.window !== undefined ? { window: value.window } : {}),
40+
};
41+
}
42+
43+
/** Versioned, human-readable clipboard payload for moving goals between blocks. */
44+
export function serializeGoalsClipboard(goals: readonly Goal[]): string {
45+
const copied = goals.map((goal) => {
46+
const value = clipboardGoal(goal);
47+
if (!value) throw new Error("Cannot copy an invalid goal");
48+
return value;
49+
});
50+
return JSON.stringify(
51+
{
52+
kind: GOAL_CLIPBOARD_KIND,
53+
version: GOAL_CLIPBOARD_VERSION,
54+
goals: copied,
55+
},
56+
null,
57+
2,
58+
);
59+
}
60+
61+
/** Read goals-only payloads and the existing Copy setup JSON (which also has a
62+
* top-level goals array). Unknown versions and malformed goals are rejected as
63+
* a whole so paste can never partially mutate a block. */
64+
export function parseGoalsClipboard(text: string): Goal[] | null {
65+
let value: unknown;
66+
try {
67+
value = JSON.parse(text);
68+
} catch {
69+
return null;
70+
}
71+
if (!isRecord(value)) return null;
72+
if ("kind" in value && (value.kind !== GOAL_CLIPBOARD_KIND || value.version !== 1)) return null;
73+
if (!Array.isArray(value.goals)) return null;
74+
const goals = value.goals.map(clipboardGoal);
75+
return goals.every((goal): goal is Goal => goal !== null) ? goals : null;
76+
}

docs/guide/blocks.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ Select **New block**, then use **+ goal** to choose an item or fluid.
3434
- Right-click a non-primary goal and select **Move to front (names the block)** to make it
3535
the primary goal.
3636

37+
Use **Copy goals** in the Goal heading, open another block, and select **Paste goals** to
38+
reuse a set of targets. Pasted goals keep their rates, stock amounts, refill windows, and
39+
source order. They are appended after the destination's existing goals, so its primary goal
40+
does not change; goods already present are skipped. Recipes, machines, modules, and other
41+
block settings are not copied.
42+
3743
Right-click a goal and select **Keep in stock instead (buffer, not throughput)** when the
3844
intent is to refill a quantity over time rather than sustain a continuous rate. Stock-only
3945
production appears separately in Factory.

0 commit comments

Comments
 (0)