v0.10.0 — Location spine: garden moved onto a shared place tree

Completes the Location feature (schema + migration landed earlier under 98cabc5).

- Hierarchical Location model is the shared spine; unlimited nesting
- Garden add/edit now picks a reusable Location (inline create) instead of
  freeform text; garden groups By Location off the real tree
- Dual-write keeps legacy Plant.zone in sync with location.name (Release A)
- /api/locations CRUD + /api/locations/[id]/log (logs a location + its sub-locations)
- Location tree pure-core (buildTree/computePath/descendantIds) with tests
- Location added to backup/restore export
- Bump APP_VERSION 0.10.0 + plain-English changelog entry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 23:36:54 -05:00
parent 6f8fe9a798
commit 9c0312676a
19 changed files with 788 additions and 103 deletions

View File

@@ -8,6 +8,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.10.0",
date: "2026-06-15",
changes: [
"Locations are now real, reusable places instead of just typed-in text. Pick where a plant lives from your saved spots, or add a new one on the fly — and the same locations will soon hold your tools, pantry, and more. Your existing plant locations carried over automatically.",
],
},
{
version: "0.9.1",
date: "2026-06-15",

40
src/lib/locations/db.ts Normal file
View File

@@ -0,0 +1,40 @@
// DB-touching Location helpers (kept thin; pure logic lives in tree.ts).
import { db } from "@/lib/db";
import { computePath, indexById, descendantIds } from "./tree";
type Client = typeof db;
/**
* Recompute the denormalized `path` breadcrumb on every Location. Homestead
* trees are tiny (dozens of rows), so a full recompute on any structural change
* is simpler and always-correct versus targeted descendant updates. Pass a
* transaction client to run inside the same tx as the mutation.
*/
export async function recomputeAllPaths(client: Client = db): Promise<void> {
const rows = await client.location.findMany({
select: { id: true, name: true, parentId: true },
});
const byId = indexById(rows);
for (const r of rows) {
await client.location.update({
where: { id: r.id },
data: { path: computePath(r.id, byId) || r.name },
});
}
}
/**
* Would setting `candidateParentId` as the parent of `nodeId` create a cycle?
* True if the candidate is the node itself or one of its descendants.
*/
export async function wouldCreateCycle(
nodeId: string,
candidateParentId: string,
client: Client = db,
): Promise<boolean> {
if (nodeId === candidateParentId) return true;
const rows = await client.location.findMany({
select: { id: true, parentId: true, name: true },
});
return descendantIds(nodeId, rows, true).includes(candidateParentId);
}

View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from "vitest";
import {
buildTree,
indexById,
computePath,
descendantIds,
flattenForSelect,
PATH_SEP,
} from "./tree";
// House Basement Shelf B, plus a sibling room and a standalone garden area.
const rows = [
{ id: "house", name: "House", parentId: null },
{ id: "base", name: "Basement", parentId: "house" },
{ id: "shelfB", name: "Shelf B", parentId: "base" },
{ id: "shelfA", name: "Shelf A", parentId: "base" },
{ id: "kitchen", name: "Kitchen", parentId: "house" },
{ id: "garden", name: "Back fence guild", parentId: null },
];
describe("buildTree", () => {
it("nests children under parents and sorts siblings by name", () => {
const roots = buildTree(rows);
// Roots sorted: "Back fence guild" before "House"
expect(roots.map((r) => r.id)).toEqual(["garden", "house"]);
const house = roots.find((r) => r.id === "house")!;
// House children sorted: Basement, Kitchen
expect(house.children.map((c) => c.id)).toEqual(["base", "kitchen"]);
// Basement children sorted: Shelf A, Shelf B
const base = house.children.find((c) => c.id === "base")!;
expect(base.children.map((c) => c.id)).toEqual(["shelfA", "shelfB"]);
});
it("assigns depth by nesting level", () => {
const roots = buildTree(rows);
const house = roots.find((r) => r.id === "house")!;
expect(house.depth).toBe(0);
expect(house.children[0].depth).toBe(1); // Basement
expect(house.children[0].children[0].depth).toBe(2); // Shelf A
});
it("treats rows with a missing parent as roots (orphans don't vanish)", () => {
const orphan = [{ id: "x", name: "Orphan", parentId: "ghost" }];
expect(buildTree(orphan).map((r) => r.id)).toEqual(["x"]);
});
it("is cycle-safe", () => {
const cyclic = [
{ id: "a", name: "A", parentId: "b" },
{ id: "b", name: "B", parentId: "a" },
];
// Neither is a real root; build must not infinite-loop.
expect(() => buildTree(cyclic)).not.toThrow();
});
});
describe("computePath", () => {
it("renders the breadcrumb from root to node", () => {
const byId = indexById(rows);
expect(computePath("shelfB", byId)).toBe(
["House", "Basement", "Shelf B"].join(PATH_SEP),
);
expect(computePath("garden", byId)).toBe("Back fence guild");
});
it("is cycle-safe", () => {
const byId = indexById([
{ id: "a", name: "A", parentId: "b" },
{ id: "b", name: "B", parentId: "a" },
]);
expect(() => computePath("a", byId)).not.toThrow();
});
});
describe("descendantIds", () => {
it("collects all descendants, excluding self by default", () => {
expect(descendantIds("house", rows).sort()).toEqual(
["base", "kitchen", "shelfA", "shelfB"].sort(),
);
});
it("includes self when asked", () => {
expect(descendantIds("base", rows, true).sort()).toEqual(
["base", "shelfA", "shelfB"].sort(),
);
});
it("returns just self (or nothing) for a leaf", () => {
expect(descendantIds("shelfA", rows)).toEqual([]);
expect(descendantIds("shelfA", rows, true)).toEqual(["shelfA"]);
});
});
describe("flattenForSelect", () => {
it("produces a depth-ordered, pre-order list", () => {
const flat = flattenForSelect(buildTree(rows));
expect(flat.map((n) => `${n.depth}:${n.id}`)).toEqual([
"0:garden",
"0:house",
"1:base",
"2:shelfA",
"2:shelfB",
"1:kitchen",
]);
// No children key leaks into the flat rows.
expect((flat[0] as Record<string, unknown>).children).toBeUndefined();
});
});

123
src/lib/locations/tree.ts Normal file
View File

@@ -0,0 +1,123 @@
// Pure helpers for the shared Location tree. Prisma-free so they're trivially
// testable — callers pass plain rows ({ id, name, parentId }). Tree depth on a
// homestead is tiny, so everything here is simple in-memory work, no recursive
// SQL. See tree.test.ts.
export const PATH_SEP = " ";
/** Minimum shape the tree helpers need. Real Location rows satisfy this. */
export interface LocationRow {
id: string;
name: string;
parentId?: string | null;
}
export type TreeNode<T extends LocationRow> = T & {
children: TreeNode<T>[];
depth: number;
};
/**
* Build a sorted forest from flat rows. Rows whose parent is missing (filtered
* out, inactive, or dangling) become roots. Cycle-safe: a node only ever has
* one parent slot, so a cycle simply drops those nodes from the roots — they
* won't crash the walk.
*/
export function buildTree<T extends LocationRow>(rows: T[]): TreeNode<T>[] {
const byId = new Map<string, TreeNode<T>>();
for (const r of rows) byId.set(r.id, { ...r, children: [], depth: 0 });
const roots: TreeNode<T>[] = [];
for (const r of rows) {
const node = byId.get(r.id)!;
const parent = r.parentId ? byId.get(r.parentId) : undefined;
if (parent && parent !== node) parent.children.push(node);
else roots.push(node);
}
const sortRec = (nodes: TreeNode<T>[], depth: number) => {
nodes.sort((a, b) => a.name.localeCompare(b.name));
for (const n of nodes) {
n.depth = depth;
sortRec(n.children, depth + 1);
}
};
sortRec(roots, 0);
return roots;
}
/** Build an id→row lookup for computePath / repeated parent walks. */
export function indexById<T extends LocationRow>(rows: T[]): Map<string, T> {
return new Map(rows.map((r) => [r.id, r]));
}
/**
* Breadcrumb path from root to the given node, e.g. "House Basement Shelf B".
* Walks parent links; cycle-safe via a seen-set.
*/
export function computePath<T extends LocationRow>(
id: string,
byId: Map<string, T>,
sep: string = PATH_SEP,
): string {
const names: string[] = [];
const seen = new Set<string>();
let cur: T | undefined = byId.get(id);
while (cur && !seen.has(cur.id)) {
seen.add(cur.id);
names.unshift(cur.name);
cur = cur.parentId ? byId.get(cur.parentId) : undefined;
}
return names.join(sep);
}
/**
* All descendant ids of `id` (children, grandchildren, …). `includeSelf` adds
* `id` itself — handy for "log care to this location and everything under it".
* Cycle-safe.
*/
export function descendantIds<T extends LocationRow>(
id: string,
rows: T[],
includeSelf = false,
): string[] {
const childrenByParent = new Map<string, string[]>();
for (const r of rows) {
if (!r.parentId) continue;
const arr = childrenByParent.get(r.parentId) ?? [];
arr.push(r.id);
childrenByParent.set(r.parentId, arr);
}
const out: string[] = includeSelf ? [id] : [];
const seen = new Set<string>([id]);
const stack = [...(childrenByParent.get(id) ?? [])];
while (stack.length) {
const cur = stack.pop()!;
if (seen.has(cur)) continue;
seen.add(cur);
out.push(cur);
stack.push(...(childrenByParent.get(cur) ?? []));
}
return out;
}
/**
* Flatten a forest into a depth-ordered list for indented <select> options.
* Indent in the UI with `" ".repeat(depth * 2)` or similar.
*/
export function flattenForSelect<T extends LocationRow>(
roots: TreeNode<T>[],
): Array<T & { depth: number }> {
const out: Array<T & { depth: number }> = [];
const walk = (nodes: TreeNode<T>[]) => {
for (const n of nodes) {
const { children, ...rest } = n;
void children;
out.push(rest as unknown as T & { depth: number });
walk(n.children);
}
};
walk(roots);
return out;
}

View File

@@ -1,2 +1,2 @@
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
export const APP_VERSION = "0.9.1";
export const APP_VERSION = "0.10.0";