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:
@@ -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
40
src/lib/locations/db.ts
Normal 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);
|
||||
}
|
||||
108
src/lib/locations/tree.test.ts
Normal file
108
src/lib/locations/tree.test.ts
Normal 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
123
src/lib/locations/tree.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user