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:
@@ -56,6 +56,25 @@ async function main() {
|
|||||||
{ commonName: "Garlic", variety: "Music", category: PlantCategory.BULB, zone: "Raised bed #1", source: "Keene Organics", plantedAt: new Date("2024-10-10") },
|
{ commonName: "Garlic", variety: "Music", category: PlantCategory.BULB, zone: "Raised bed #1", source: "Keene Organics", plantedAt: new Date("2024-10-10") },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Starter Location tree — a root property node with the garden areas under it.
|
||||||
|
const root = await db.location.upsert({
|
||||||
|
where: { id: "seed-loc-root" },
|
||||||
|
update: {},
|
||||||
|
create: { id: "seed-loc-root", name: "Moon Homestead", kind: "OTHER", path: "Moon Homestead" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const zoneNames = Array.from(new Set(plants.map((p) => p.zone).filter(Boolean) as string[]));
|
||||||
|
const zoneToLocId = new Map<string, string>();
|
||||||
|
for (const z of zoneNames) {
|
||||||
|
const id = `seed-loc-${z.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
||||||
|
await db.location.upsert({
|
||||||
|
where: { id },
|
||||||
|
update: {},
|
||||||
|
create: { id, name: z, kind: "AREA", parentId: root.id, path: `Moon Homestead › ${z}` },
|
||||||
|
});
|
||||||
|
zoneToLocId.set(z, id);
|
||||||
|
}
|
||||||
|
|
||||||
for (const p of plants) {
|
for (const p of plants) {
|
||||||
await db.plant.upsert({
|
await db.plant.upsert({
|
||||||
where: { id: `seed-${p.commonName.toLowerCase().replace(/\s/g, "-")}-${p.variety?.toLowerCase().replace(/\s/g, "-") ?? "0"}` },
|
where: { id: `seed-${p.commonName.toLowerCase().replace(/\s/g, "-")}-${p.variety?.toLowerCase().replace(/\s/g, "-") ?? "0"}` },
|
||||||
@@ -63,6 +82,7 @@ async function main() {
|
|||||||
create: {
|
create: {
|
||||||
id: `seed-${p.commonName.toLowerCase().replace(/\s/g, "-")}-${p.variety?.toLowerCase().replace(/\s/g, "-") ?? "0"}`,
|
id: `seed-${p.commonName.toLowerCase().replace(/\s/g, "-")}-${p.variety?.toLowerCase().replace(/\s/g, "-") ?? "0"}`,
|
||||||
...p,
|
...p,
|
||||||
|
locationId: p.zone ? zoneToLocId.get(p.zone) : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,18 +16,18 @@ export async function generateMetadata({ params }: { params: { id: string } }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function PlantDetailPage({ params }: { params: { id: string } }) {
|
export default async function PlantDetailPage({ params }: { params: { id: string } }) {
|
||||||
const [plant, allPlants, groups] = await Promise.all([
|
const [plant, locations, groups] = await Promise.all([
|
||||||
db.plant.findUnique({
|
db.plant.findUnique({
|
||||||
where: { id: params.id },
|
where: { id: params.id },
|
||||||
include: { logs: { orderBy: { date: "desc" } } },
|
include: { logs: { orderBy: { date: "desc" } }, location: { select: { id: true, name: true } } },
|
||||||
}),
|
}),
|
||||||
db.plant.findMany({ where: { active: true }, select: { zone: true } }),
|
db.location.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||||
db.plantGroup.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
db.plantGroup.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!plant || !plant.active) notFound();
|
if (!plant || !plant.active) notFound();
|
||||||
|
|
||||||
const zones = Array.from(new Set(allPlants.map((p) => p.zone).filter(Boolean) as string[])).sort();
|
const placeName = plant.location?.name ?? plant.zone ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-2xl">
|
<div className="space-y-6 max-w-2xl">
|
||||||
@@ -48,7 +48,7 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<EditPlantButton plant={plant} zones={zones} groups={groups} />
|
<EditPlantButton plant={plant} locations={locations} groups={groups} />
|
||||||
<DeletePlantButton plantId={plant.id} plantName={plant.commonName} />
|
<DeletePlantButton plantId={plant.id} plantName={plant.commonName} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -60,10 +60,10 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-4 space-y-2 text-sm">
|
<CardContent className="pt-4 space-y-2 text-sm">
|
||||||
{plant.zone && (
|
{placeName && (
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
<MapPin className="h-4 w-4 shrink-0" />
|
<MapPin className="h-4 w-4 shrink-0" />
|
||||||
<span>{plant.zone}</span>
|
<span>{placeName}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{plant.plantedAt && (
|
{plant.plantedAt && (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { GardenGrid } from "@/components/garden/garden-grid";
|
|||||||
export const metadata = { title: "Garden" };
|
export const metadata = { title: "Garden" };
|
||||||
|
|
||||||
export default async function GardenPage() {
|
export default async function GardenPage() {
|
||||||
const [plants, groups] = await Promise.all([
|
const [plants, groups, locations] = await Promise.all([
|
||||||
db.plant.findMany({
|
db.plant.findMany({
|
||||||
where: { active: true },
|
where: { active: true },
|
||||||
orderBy: [{ commonName: "asc" }],
|
orderBy: [{ commonName: "asc" }],
|
||||||
@@ -13,6 +13,7 @@ export default async function GardenPage() {
|
|||||||
_count: { select: { logs: true } },
|
_count: { select: { logs: true } },
|
||||||
logs: { orderBy: { date: "desc" }, take: 1, select: { date: true, type: true } },
|
logs: { orderBy: { date: "desc" }, take: 1, select: { date: true, type: true } },
|
||||||
group: { select: { id: true, name: true } },
|
group: { select: { id: true, name: true } },
|
||||||
|
location: { select: { id: true, name: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
db.plantGroup.findMany({
|
db.plantGroup.findMany({
|
||||||
@@ -20,10 +21,13 @@ export default async function GardenPage() {
|
|||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
include: { _count: { select: { plants: true } } },
|
include: { _count: { select: { plants: true } } },
|
||||||
}),
|
}),
|
||||||
|
db.location.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const zones = Array.from(new Set(plants.map((p) => p.zone).filter(Boolean) as string[])).sort();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -33,7 +37,7 @@ export default async function GardenPage() {
|
|||||||
{plants.length} {plants.length === 1 ? "plant" : "plants"} on your acre
|
{plants.length} {plants.length === 1 ? "plant" : "plants"} on your acre
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<AddPlantButton groups={groups} zones={zones} />
|
<AddPlantButton groups={groups} locations={locations} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GardenGrid plants={plants} groups={groups} />
|
<GardenGrid plants={plants} groups={groups} />
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [users, plantGroups, plants, plantLogs, tasks, taskCompletions, auditEvents] =
|
const [locations, users, plantGroups, plants, plantLogs, tasks, taskCompletions, auditEvents] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
db.location.findMany(),
|
||||||
db.user.findMany(),
|
db.user.findMany(),
|
||||||
db.plantGroup.findMany(),
|
db.plantGroup.findMany(),
|
||||||
db.plant.findMany(),
|
db.plant.findMany(),
|
||||||
@@ -24,11 +25,12 @@ export async function GET() {
|
|||||||
const manifest = {
|
const manifest = {
|
||||||
version: 1,
|
version: 1,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
tables: ["users", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "auditEvents"],
|
tables: ["locations", "users", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "auditEvents"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const zip = zipSync({
|
const zip = zipSync({
|
||||||
"manifest.json": strToU8(JSON.stringify(manifest, null, 2)),
|
"manifest.json": strToU8(JSON.stringify(manifest, null, 2)),
|
||||||
|
"locations.json": strToU8(JSON.stringify(locations, null, 2)),
|
||||||
"users.json": strToU8(JSON.stringify(users, null, 2)),
|
"users.json": strToU8(JSON.stringify(users, null, 2)),
|
||||||
"plantGroups.json": strToU8(JSON.stringify(plantGroups, null, 2)),
|
"plantGroups.json": strToU8(JSON.stringify(plantGroups, null, 2)),
|
||||||
"plants.json": strToU8(JSON.stringify(plants, null, 2)),
|
"plants.json": strToU8(JSON.stringify(plants, null, 2)),
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ error: "Unsupported backup version" }, { status: 400 });
|
return NextResponse.json({ error: "Unsupported backup version" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Older backups (pre-v0.10) won't have locations.json — tolerate its absence.
|
||||||
|
const locations = files["locations.json"] ? parse("locations.json") : [];
|
||||||
const users = parse("users.json");
|
const users = parse("users.json");
|
||||||
const plantGroups = parse("plantGroups.json");
|
const plantGroups = parse("plantGroups.json");
|
||||||
const plants = parse("plants.json");
|
const plants = parse("plants.json");
|
||||||
@@ -35,17 +37,21 @@ export async function POST(req: Request) {
|
|||||||
const taskCompletions = parse("taskCompletions.json");
|
const taskCompletions = parse("taskCompletions.json");
|
||||||
const auditEvents = parse("auditEvents.json");
|
const auditEvents = parse("auditEvents.json");
|
||||||
|
|
||||||
// Restore inside a transaction — wipe then reload in FK order
|
// Restore inside a transaction — wipe then reload in FK order.
|
||||||
await db.$transaction(async (tx) => {
|
await db.$transaction(async (tx) => {
|
||||||
await tx.taskCompletion.deleteMany();
|
await tx.taskCompletion.deleteMany();
|
||||||
await tx.plantLog.deleteMany();
|
await tx.plantLog.deleteMany();
|
||||||
await tx.task.deleteMany();
|
await tx.task.deleteMany();
|
||||||
await tx.plant.deleteMany();
|
await tx.plant.deleteMany();
|
||||||
await tx.plantGroup.deleteMany();
|
await tx.plantGroup.deleteMany();
|
||||||
|
await tx.location.deleteMany();
|
||||||
await tx.auditEvent.deleteMany();
|
await tx.auditEvent.deleteMany();
|
||||||
await tx.user.deleteMany();
|
await tx.user.deleteMany();
|
||||||
|
|
||||||
if (users.length) await tx.user.createMany({ data: users });
|
if (users.length) await tx.user.createMany({ data: users });
|
||||||
|
// Location self-references parentId; a single createMany is one statement,
|
||||||
|
// so the FK is validated only after every row exists — order-independent.
|
||||||
|
if (locations.length) await tx.location.createMany({ data: locations });
|
||||||
if (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups });
|
if (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups });
|
||||||
if (plants.length) await tx.plant.createMany({ data: plants });
|
if (plants.length) await tx.plant.createMany({ data: plants });
|
||||||
if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs });
|
if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs });
|
||||||
|
|||||||
71
src/app/api/locations/[id]/log/route.ts
Normal file
71
src/app/api/locations/[id]/log/route.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { requireAuth } from "@/lib/auth";
|
||||||
|
import { descendantIds } from "@/lib/locations/tree";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
quantity: z.string().optional(),
|
||||||
|
date: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/locations/[id]/log — append a log to every active plant in this
|
||||||
|
// location and its sub-locations (e.g. "watered the whole back yard").
|
||||||
|
export async function POST(req: Request, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const session = await requireAuth();
|
||||||
|
|
||||||
|
const location = await db.location.findUnique({ where: { id: params.id } });
|
||||||
|
if (!location || !location.active)
|
||||||
|
return NextResponse.json({ error: "Location not found" }, { status: 404 });
|
||||||
|
|
||||||
|
// This location + everything nested under it.
|
||||||
|
const allRows = await db.location.findMany({ select: { id: true, parentId: true, name: true } });
|
||||||
|
const locationIds = descendantIds(params.id, allRows, true);
|
||||||
|
|
||||||
|
const plants = await db.plant.findMany({
|
||||||
|
where: { locationId: { in: locationIds }, active: true },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (plants.length === 0)
|
||||||
|
return NextResponse.json({ error: "No plants found in this location" }, { status: 404 });
|
||||||
|
|
||||||
|
const data = schema.parse(await req.json());
|
||||||
|
const date = data.date ? new Date(data.date) : new Date();
|
||||||
|
|
||||||
|
const logs = await db.$transaction(
|
||||||
|
plants.map((p) =>
|
||||||
|
db.plantLog.create({
|
||||||
|
data: {
|
||||||
|
plantId: p.id,
|
||||||
|
type: data.type,
|
||||||
|
date,
|
||||||
|
notes: data.notes?.trim() || null,
|
||||||
|
quantity: data.quantity?.trim() || null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.auditEvent.create({
|
||||||
|
data: {
|
||||||
|
action: "location.log",
|
||||||
|
entityId: params.id,
|
||||||
|
actorId: session.user.id,
|
||||||
|
payload: { location: location.name, type: data.type, plantCount: logs.length },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ count: logs.length }, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError)
|
||||||
|
return NextResponse.json({ error: err.issues[0]?.message }, { status: 400 });
|
||||||
|
if (err instanceof Error && err.message === "Unauthorized")
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
console.error(err);
|
||||||
|
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/app/api/locations/[id]/route.ts
Normal file
106
src/app/api/locations/[id]/route.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { LocationKind } from "@prisma/client";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { requireAuth, isAdmin } from "@/lib/auth";
|
||||||
|
import { recomputeAllPaths, wouldCreateCycle } from "@/lib/locations/db";
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
kind: z.nativeEnum(LocationKind).optional(),
|
||||||
|
parentId: z.string().nullable().optional(), // null → move to top level
|
||||||
|
notes: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const session = await requireAuth();
|
||||||
|
const location = await db.location.findUnique({ where: { id: params.id } });
|
||||||
|
if (!location || !location.active)
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const data = updateSchema.parse(await req.json());
|
||||||
|
|
||||||
|
// Guard against cycles when re-parenting.
|
||||||
|
if (data.parentId) {
|
||||||
|
if (await wouldCreateCycle(params.id, data.parentId))
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "A location can't be moved under itself or its own descendant" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
const parent = await db.location.findUnique({ where: { id: data.parentId } });
|
||||||
|
if (!parent) return NextResponse.json({ error: "Parent location not found" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await db.$transaction(async (tx) => {
|
||||||
|
const u = await tx.location.update({
|
||||||
|
where: { id: params.id },
|
||||||
|
data: {
|
||||||
|
...(data.name !== undefined ? { name: data.name.trim() } : {}),
|
||||||
|
...(data.kind !== undefined ? { kind: data.kind } : {}),
|
||||||
|
...(data.parentId !== undefined ? { parentId: data.parentId } : {}),
|
||||||
|
...(data.notes !== undefined ? { notes: data.notes?.trim() || null } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await recomputeAllPaths(tx as unknown as typeof db);
|
||||||
|
await tx.auditEvent.create({
|
||||||
|
data: {
|
||||||
|
action: "location.update",
|
||||||
|
entityId: params.id,
|
||||||
|
actorId: session.user.id,
|
||||||
|
payload: { name: u.name },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return u;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError)
|
||||||
|
return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
|
||||||
|
if (err instanceof Error && err.message === "Unauthorized")
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
console.error(err);
|
||||||
|
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const session = await requireAuth();
|
||||||
|
if (!isAdmin(session.user.role))
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
const location = await db.location.findUnique({ where: { id: params.id } });
|
||||||
|
if (!location || !location.active)
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
// Don't orphan live records — require the location be emptied first.
|
||||||
|
const [plantCount, childCount] = await Promise.all([
|
||||||
|
db.plant.count({ where: { locationId: params.id, active: true } }),
|
||||||
|
db.location.count({ where: { parentId: params.id, active: true } }),
|
||||||
|
]);
|
||||||
|
if (plantCount > 0 || childCount > 0)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Move the plants and sub-locations out of this location first" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.location.update({ where: { id: params.id }, data: { active: false } });
|
||||||
|
await db.auditEvent.create({
|
||||||
|
data: {
|
||||||
|
action: "location.delete",
|
||||||
|
entityId: params.id,
|
||||||
|
actorId: session.user.id,
|
||||||
|
payload: { name: location.name },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message === "Unauthorized")
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
console.error(err);
|
||||||
|
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/app/api/locations/route.ts
Normal file
78
src/app/api/locations/route.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { LocationKind } from "@prisma/client";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { requireAuth } from "@/lib/auth";
|
||||||
|
import { recomputeAllPaths } from "@/lib/locations/db";
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
kind: z.nativeEnum(LocationKind).optional(),
|
||||||
|
parentId: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/locations — flat list of active locations (client builds the tree).
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireAuth();
|
||||||
|
const locations = await db.location.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
include: { _count: { select: { plants: { where: { active: true } } } } },
|
||||||
|
});
|
||||||
|
return NextResponse.json(locations);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message === "Unauthorized")
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
console.error(err);
|
||||||
|
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/locations — create a location node.
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireAuth();
|
||||||
|
const data = createSchema.parse(await req.json());
|
||||||
|
|
||||||
|
const name = data.name.trim();
|
||||||
|
if (!name) return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
||||||
|
|
||||||
|
// Validate parent exists (if given).
|
||||||
|
if (data.parentId) {
|
||||||
|
const parent = await db.location.findUnique({ where: { id: data.parentId } });
|
||||||
|
if (!parent) return NextResponse.json({ error: "Parent location not found" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = await db.$transaction(async (tx) => {
|
||||||
|
const created = await tx.location.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
kind: data.kind ?? LocationKind.AREA,
|
||||||
|
parentId: data.parentId || null,
|
||||||
|
notes: data.notes?.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await recomputeAllPaths(tx as unknown as typeof db);
|
||||||
|
await tx.auditEvent.create({
|
||||||
|
data: {
|
||||||
|
action: "location.create",
|
||||||
|
entityId: created.id,
|
||||||
|
actorId: session.user.id,
|
||||||
|
payload: { name: created.name, kind: created.kind, parentId: created.parentId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(location, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError)
|
||||||
|
return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
|
||||||
|
if (err instanceof Error && err.message === "Unauthorized")
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
console.error(err);
|
||||||
|
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,23 @@ const updateSchema = z.object({
|
|||||||
variety: z.string().optional(),
|
variety: z.string().optional(),
|
||||||
category: z.string().min(1),
|
category: z.string().min(1),
|
||||||
zone: z.string().optional(),
|
zone: z.string().optional(),
|
||||||
|
locationId: z.string().nullable().optional(),
|
||||||
plantedAt: z.string().optional(),
|
plantedAt: z.string().optional(),
|
||||||
source: z.string().optional(),
|
source: z.string().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
groupId: z.string().nullable().optional(),
|
groupId: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dual-write helper (Release A): keep the legacy `zone` string in sync with the
|
||||||
|
// chosen location's name until Release B drops the column.
|
||||||
|
async function resolveZone(locationId?: string | null, zone?: string): Promise<string | null> {
|
||||||
|
if (locationId) {
|
||||||
|
const loc = await db.location.findUnique({ where: { id: locationId }, select: { name: true } });
|
||||||
|
if (loc) return loc.name;
|
||||||
|
}
|
||||||
|
return zone?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||||
try {
|
try {
|
||||||
const session = await requireAuth();
|
const session = await requireAuth();
|
||||||
@@ -35,6 +46,8 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
|
|||||||
? await fetchPlantImageUrl(species, commonName)
|
? await fetchPlantImageUrl(species, commonName)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const zone = await resolveZone(data.locationId, data.zone);
|
||||||
|
|
||||||
const updated = await db.plant.update({
|
const updated = await db.plant.update({
|
||||||
where: { id: params.id },
|
where: { id: params.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -42,7 +55,8 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
|
|||||||
species,
|
species,
|
||||||
variety: data.variety?.trim() || null,
|
variety: data.variety?.trim() || null,
|
||||||
category: data.category as PlantCategory,
|
category: data.category as PlantCategory,
|
||||||
zone: data.zone?.trim() || null,
|
zone, // dual-write: kept in sync with location.name until Release B
|
||||||
|
locationId: data.locationId ?? null,
|
||||||
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
|
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
|
||||||
source: data.source?.trim() || null,
|
source: data.source?.trim() || null,
|
||||||
notes: data.notes?.trim() || null,
|
notes: data.notes?.trim() || null,
|
||||||
|
|||||||
@@ -10,12 +10,24 @@ const createSchema = z.object({
|
|||||||
variety: z.string().optional(),
|
variety: z.string().optional(),
|
||||||
category: z.string().min(1),
|
category: z.string().min(1),
|
||||||
zone: z.string().optional(),
|
zone: z.string().optional(),
|
||||||
|
locationId: z.string().optional(),
|
||||||
plantedAt: z.string().optional(),
|
plantedAt: z.string().optional(),
|
||||||
source: z.string().optional(),
|
source: z.string().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
groupId: z.string().optional(),
|
groupId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dual-write helper (Release A): resolve a locationId to its name so the legacy
|
||||||
|
// `zone` column stays populated until Release B drops it. Returns the zone
|
||||||
|
// string to stamp, falling back to the freeform zone when no location is given.
|
||||||
|
async function resolveZone(locationId?: string, zone?: string): Promise<string | null> {
|
||||||
|
if (locationId) {
|
||||||
|
const loc = await db.location.findUnique({ where: { id: locationId }, select: { name: true } });
|
||||||
|
if (loc) return loc.name;
|
||||||
|
}
|
||||||
|
return zone?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await requireAuth();
|
const session = await requireAuth();
|
||||||
@@ -25,6 +37,7 @@ export async function POST(req: Request) {
|
|||||||
const species = data.species?.trim() || null;
|
const species = data.species?.trim() || null;
|
||||||
const commonName = data.commonName.trim();
|
const commonName = data.commonName.trim();
|
||||||
const imageUrl = await fetchPlantImageUrl(species, commonName);
|
const imageUrl = await fetchPlantImageUrl(species, commonName);
|
||||||
|
const zone = await resolveZone(data.locationId, data.zone);
|
||||||
|
|
||||||
const plant = await db.plant.create({
|
const plant = await db.plant.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -32,7 +45,8 @@ export async function POST(req: Request) {
|
|||||||
species,
|
species,
|
||||||
variety: data.variety?.trim() || null,
|
variety: data.variety?.trim() || null,
|
||||||
category: data.category as PlantCategory,
|
category: data.category as PlantCategory,
|
||||||
zone: data.zone?.trim() || null,
|
zone, // dual-write: kept in sync with location.name until Release B
|
||||||
|
locationId: data.locationId || null,
|
||||||
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
|
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
|
||||||
source: data.source?.trim() || null,
|
source: data.source?.trim() || null,
|
||||||
notes: data.notes?.trim() || null,
|
notes: data.notes?.trim() || null,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
|
|||||||
import { searchPlants, type PlantSuggestion } from "@/lib/garden/plant-db";
|
import { searchPlants, type PlantSuggestion } from "@/lib/garden/plant-db";
|
||||||
|
|
||||||
type Group = { id: string; name: string };
|
type Group = { id: string; name: string };
|
||||||
|
type LocationOption = { id: string; name: string };
|
||||||
|
|
||||||
const BLANK = {
|
const BLANK = {
|
||||||
commonName: "",
|
commonName: "",
|
||||||
@@ -33,12 +34,19 @@ const BLANK = {
|
|||||||
newGroupName: "",
|
newGroupName: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[]; zones?: string[] }) {
|
export function AddPlantButton({
|
||||||
|
groups = [],
|
||||||
|
locations = [],
|
||||||
|
}: {
|
||||||
|
groups?: Group[];
|
||||||
|
locations?: LocationOption[];
|
||||||
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [fields, setFields] = useState(BLANK);
|
const [fields, setFields] = useState(BLANK);
|
||||||
const [selectedZones, setSelectedZones] = useState<string[]>([]);
|
const [locationOptions, setLocationOptions] = useState<LocationOption[]>(locations);
|
||||||
const [newZoneName, setNewZoneName] = useState("");
|
const [selectedLocationIds, setSelectedLocationIds] = useState<string[]>([]);
|
||||||
const [addingZone, setAddingZone] = useState(false);
|
const [newLocationName, setNewLocationName] = useState("");
|
||||||
|
const [addingLocation, setAddingLocation] = useState(false);
|
||||||
const [creatingGroup, setCreatingGroup] = useState(false);
|
const [creatingGroup, setCreatingGroup] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -47,25 +55,46 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// All zones including newly typed ones not yet in the DB
|
|
||||||
const allZones = Array.from(new Set([...zones, ...selectedZones]));
|
|
||||||
|
|
||||||
function set(key: keyof typeof BLANK, value: string) {
|
function set(key: keyof typeof BLANK, value: string) {
|
||||||
setFields((f) => ({ ...f, [key]: value }));
|
setFields((f) => ({ ...f, [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleZone(zone: string) {
|
function nameFor(id: string) {
|
||||||
setSelectedZones((prev) =>
|
return locationOptions.find((l) => l.id === id)?.name ?? "Location";
|
||||||
prev.includes(zone) ? prev.filter((z) => z !== zone) : [...prev, zone]
|
}
|
||||||
|
|
||||||
|
function toggleLocation(id: string) {
|
||||||
|
setSelectedLocationIds((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addNewZone() {
|
// Persist new locations to the shared spine immediately so the plant gets a
|
||||||
const name = newZoneName.trim();
|
// real locationId (the freeform "zone" string is dual-written server-side).
|
||||||
|
async function addNewLocation() {
|
||||||
|
const name = newLocationName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
if (!selectedZones.includes(name)) setSelectedZones((prev) => [...prev, name]);
|
const existing = locationOptions.find((l) => l.name.toLowerCase() === name.toLowerCase());
|
||||||
setNewZoneName("");
|
if (existing) {
|
||||||
setAddingZone(false);
|
if (!selectedLocationIds.includes(existing.id)) toggleLocation(existing.id);
|
||||||
|
setNewLocationName("");
|
||||||
|
setAddingLocation(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch("/api/locations", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
toast({ title: "Error", description: "Could not add location.", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loc = await res.json();
|
||||||
|
setLocationOptions((prev) => [...prev, { id: loc.id, name: loc.name }]);
|
||||||
|
setSelectedLocationIds((prev) => [...prev, loc.id]);
|
||||||
|
setNewLocationName("");
|
||||||
|
setAddingLocation(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onNameChange(value: string) {
|
function onNameChange(value: string) {
|
||||||
@@ -90,9 +119,9 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
|
|||||||
function handleClose() {
|
function handleClose() {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setFields(BLANK);
|
setFields(BLANK);
|
||||||
setSelectedZones([]);
|
setSelectedLocationIds([]);
|
||||||
setNewZoneName("");
|
setNewLocationName("");
|
||||||
setAddingZone(false);
|
setAddingLocation(false);
|
||||||
setError("");
|
setError("");
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
@@ -130,14 +159,14 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
|
|||||||
groupId,
|
groupId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create one plant per selected zone (or one with no zone if none selected).
|
// Create one plant per selected location (or one with no location if none).
|
||||||
const zonesToCreate = selectedZones.length > 0 ? selectedZones : [undefined];
|
const locsToCreate = selectedLocationIds.length > 0 ? selectedLocationIds : [undefined];
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
zonesToCreate.map((zone) =>
|
locsToCreate.map((locationId) =>
|
||||||
fetch("/api/plants", {
|
fetch("/api/plants", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ...base, zone }),
|
body: JSON.stringify({ ...base, locationId }),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -149,7 +178,7 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const count = zonesToCreate.length;
|
const count = locsToCreate.length;
|
||||||
toast({
|
toast({
|
||||||
title: "Plant added!",
|
title: "Plant added!",
|
||||||
description: count > 1
|
description: count > 1
|
||||||
@@ -254,23 +283,23 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Multi-location picker */}
|
{/* Multi-location picker (shared Location spine) */}
|
||||||
<div className="col-span-2 space-y-1.5">
|
<div className="col-span-2 space-y-1.5">
|
||||||
<Label>
|
<Label>
|
||||||
Location(s) on your property
|
Location(s) on your property
|
||||||
<span className="text-muted-foreground font-normal ml-1">(pick as many as you like)</span>
|
<span className="text-muted-foreground font-normal ml-1">(pick as many as you like)</span>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
{/* Selected zones as chips */}
|
{/* Selected locations as chips */}
|
||||||
{selectedZones.length > 0 && (
|
{selectedLocationIds.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||||
{selectedZones.map((z) => (
|
{selectedLocationIds.map((id) => (
|
||||||
<Badge key={z} variant="secondary" className="gap-1 pr-1">
|
<Badge key={id} variant="secondary" className="gap-1 pr-1">
|
||||||
<MapPin className="h-3 w-3" />
|
<MapPin className="h-3 w-3" />
|
||||||
{z}
|
{nameFor(id)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleZone(z)}
|
onClick={() => toggleLocation(id)}
|
||||||
className="ml-0.5 hover:text-destructive transition-colors"
|
className="ml-0.5 hover:text-destructive transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
@@ -280,45 +309,45 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Existing zones as checkboxes */}
|
{/* Existing locations as checkboxes */}
|
||||||
{allZones.length > 0 && (
|
{locationOptions.length > 0 && (
|
||||||
<div className="border rounded-md divide-y max-h-36 overflow-y-auto">
|
<div className="border rounded-md divide-y max-h-36 overflow-y-auto">
|
||||||
{allZones.map((z) => (
|
{locationOptions.map((loc) => (
|
||||||
<label
|
<label
|
||||||
key={z}
|
key={loc.id}
|
||||||
className="flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-accent transition-colors text-sm"
|
className="flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-accent transition-colors text-sm"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedZones.includes(z)}
|
checked={selectedLocationIds.includes(loc.id)}
|
||||||
onChange={() => toggleZone(z)}
|
onChange={() => toggleLocation(loc.id)}
|
||||||
className="accent-[hsl(var(--leaf))]"
|
className="accent-[hsl(var(--leaf))]"
|
||||||
/>
|
/>
|
||||||
{z}
|
{loc.name}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add new zone */}
|
{/* Add new location */}
|
||||||
{addingZone ? (
|
{addingLocation ? (
|
||||||
<div className="flex gap-2 mt-1">
|
<div className="flex gap-2 mt-1">
|
||||||
<Input
|
<Input
|
||||||
placeholder="e.g. Front bed, Back fence guild…"
|
placeholder="e.g. Front bed, Back fence guild…"
|
||||||
value={newZoneName}
|
value={newLocationName}
|
||||||
onChange={(e) => setNewZoneName(e.target.value)}
|
onChange={(e) => setNewLocationName(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewZone(); } }}
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewLocation(); } }}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button type="button" size="sm" onClick={addNewZone}>Add</Button>
|
<Button type="button" size="sm" onClick={addNewLocation}>Add</Button>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => { setAddingZone(false); setNewZoneName(""); }}>
|
<Button type="button" variant="outline" size="sm" onClick={() => { setAddingLocation(false); setNewLocationName(""); }}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAddingZone(true)}
|
onClick={() => setAddingLocation(true)}
|
||||||
className="text-sm text-[hsl(var(--leaf))] hover:underline mt-1"
|
className="text-sm text-[hsl(var(--leaf))] hover:underline mt-1"
|
||||||
>
|
>
|
||||||
+ Add new location
|
+ Add new location
|
||||||
@@ -398,7 +427,7 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
|
|||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={handleClose}>Cancel</Button>
|
<Button type="button" variant="outline" onClick={handleClose}>Cancel</Button>
|
||||||
<Button type="submit" disabled={busy}>
|
<Button type="submit" disabled={busy}>
|
||||||
{busy ? "Saving…" : selectedZones.length > 1 ? `Add to ${selectedZones.length} locations` : "Add plant"}
|
{busy ? "Saving…" : selectedLocationIds.length > 1 ? `Add to ${selectedLocationIds.length} locations` : "Add plant"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ import { useToast } from "@/hooks/use-toast";
|
|||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
|
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
|
||||||
|
|
||||||
|
type LocationOption = { id: string; name: string };
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
commonName: z.string().min(1, "Name is required"),
|
commonName: z.string().min(1, "Name is required"),
|
||||||
species: z.string().optional(),
|
species: z.string().optional(),
|
||||||
variety: z.string().optional(),
|
variety: z.string().optional(),
|
||||||
category: z.nativeEnum(PlantCategory),
|
category: z.nativeEnum(PlantCategory),
|
||||||
zone: z.string().optional(),
|
locationId: z.string().optional(),
|
||||||
plantedAt: z.string().optional(),
|
plantedAt: z.string().optional(),
|
||||||
source: z.string().optional(),
|
source: z.string().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
@@ -37,10 +39,19 @@ function toDateInput(d: Date | null): string {
|
|||||||
return new Date(d).toISOString().split("T")[0];
|
return new Date(d).toISOString().split("T")[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Plant; zones?: string[]; groups?: { id: string; name: string }[] }) {
|
export function EditPlantButton({
|
||||||
|
plant,
|
||||||
|
locations = [],
|
||||||
|
groups = [],
|
||||||
|
}: {
|
||||||
|
plant: Plant;
|
||||||
|
locations?: LocationOption[];
|
||||||
|
groups?: { id: string; name: string }[];
|
||||||
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [creatingZone, setCreatingZone] = useState(false);
|
const [locationOptions, setLocationOptions] = useState<LocationOption[]>(locations);
|
||||||
const [newZoneName, setNewZoneName] = useState("");
|
const [creatingLocation, setCreatingLocation] = useState(false);
|
||||||
|
const [newLocationName, setNewLocationName] = useState("");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -51,19 +62,44 @@ export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Pla
|
|||||||
species: plant.species ?? "",
|
species: plant.species ?? "",
|
||||||
variety: plant.variety ?? "",
|
variety: plant.variety ?? "",
|
||||||
category: plant.category,
|
category: plant.category,
|
||||||
zone: plant.zone ?? "",
|
locationId: plant.locationId ?? "",
|
||||||
plantedAt: toDateInput(plant.plantedAt),
|
plantedAt: toDateInput(plant.plantedAt),
|
||||||
source: plant.source ?? "",
|
source: plant.source ?? "",
|
||||||
notes: plant.notes ?? "",
|
notes: plant.notes ?? "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function addNewLocation() {
|
||||||
|
const name = newLocationName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const existing = locationOptions.find((l) => l.name.toLowerCase() === name.toLowerCase());
|
||||||
|
if (existing) {
|
||||||
|
form.setValue("locationId", existing.id);
|
||||||
|
setNewLocationName("");
|
||||||
|
setCreatingLocation(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch("/api/locations", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
toast({ title: "Error", description: "Could not add location.", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loc = await res.json();
|
||||||
|
setLocationOptions((prev) => [...prev, { id: loc.id, name: loc.name }]);
|
||||||
|
form.setValue("locationId", loc.id);
|
||||||
|
setNewLocationName("");
|
||||||
|
setCreatingLocation(false);
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmit(values: FormValues) {
|
async function onSubmit(values: FormValues) {
|
||||||
if (newZoneName.trim()) values.zone = newZoneName.trim();
|
|
||||||
const res = await fetch(`/api/plants/${plant.id}`, {
|
const res = await fetch(`/api/plants/${plant.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(values),
|
body: JSON.stringify({ ...values, locationId: values.locationId || null }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
@@ -127,12 +163,12 @@ export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Pla
|
|||||||
|
|
||||||
<div className="col-span-2 space-y-1.5">
|
<div className="col-span-2 space-y-1.5">
|
||||||
<Label>Location on your property</Label>
|
<Label>Location on your property</Label>
|
||||||
{!creatingZone ? (
|
{!creatingLocation ? (
|
||||||
<Select
|
<Select
|
||||||
defaultValue={plant.zone ?? ""}
|
defaultValue={plant.locationId ?? ""}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
if (v === "__new__") { setCreatingZone(true); form.setValue("zone", ""); }
|
if (v === "__new__") { setCreatingLocation(true); form.setValue("locationId", ""); }
|
||||||
else { form.setValue("zone", v); setNewZoneName(""); }
|
else { form.setValue("locationId", v); setNewLocationName(""); }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -140,8 +176,8 @@ export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Pla
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="">No location</SelectItem>
|
<SelectItem value="">No location</SelectItem>
|
||||||
{zones.map((z) => (
|
{locationOptions.map((loc) => (
|
||||||
<SelectItem key={z} value={z}>{z}</SelectItem>
|
<SelectItem key={loc.id} value={loc.id}>{loc.name}</SelectItem>
|
||||||
))}
|
))}
|
||||||
<SelectItem value="__new__">+ Add new location…</SelectItem>
|
<SelectItem value="__new__">+ Add new location…</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -150,11 +186,13 @@ export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Pla
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="e.g. Front bed, Back fence guild, Raised bed #1"
|
placeholder="e.g. Front bed, Back fence guild, Raised bed #1"
|
||||||
value={newZoneName}
|
value={newLocationName}
|
||||||
onChange={(e) => setNewZoneName(e.target.value)}
|
onChange={(e) => setNewLocationName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewLocation(); } }}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => { setCreatingZone(false); setNewZoneName(""); }}>
|
<Button type="button" size="sm" onClick={addNewLocation}>Add</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => { setCreatingLocation(false); setNewLocationName(""); }}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Leaf, MapPin, CalendarDays, ChevronDown, ChevronRight, Users } from "lu
|
|||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
import { CATEGORY_LABELS } from "@/lib/garden/categories";
|
import { CATEGORY_LABELS } from "@/lib/garden/categories";
|
||||||
import { GroupLogButton } from "@/components/garden/group-log-button";
|
import { GroupLogButton } from "@/components/garden/group-log-button";
|
||||||
import { ZoneLogButton } from "@/components/garden/zone-log-button";
|
import { LocationLogButton } from "@/components/garden/location-log-button";
|
||||||
|
|
||||||
type Plant = {
|
type Plant = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,6 +18,8 @@ type Plant = {
|
|||||||
species: string | null;
|
species: string | null;
|
||||||
category: string;
|
category: string;
|
||||||
zone: string | null;
|
zone: string | null;
|
||||||
|
locationId: string | null;
|
||||||
|
location: { id: string; name: string } | null;
|
||||||
plantedAt: Date | null;
|
plantedAt: Date | null;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
group: { id: string; name: string } | null;
|
group: { id: string; name: string } | null;
|
||||||
@@ -25,6 +27,11 @@ type Plant = {
|
|||||||
logs: { date: Date; type: string }[];
|
logs: { date: Date; type: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Dual-read (Release A): prefer the Location name, fall back to the legacy zone.
|
||||||
|
function placeName(p: Plant): string | null {
|
||||||
|
return p.location?.name ?? p.zone ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
type Group = {
|
type Group = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -174,7 +181,7 @@ function VarietyRow({ plants, expanded, toggle }: {
|
|||||||
{plants.map((p) => (
|
{plants.map((p) => (
|
||||||
<Badge key={p.id} variant="outline" className="text-xs gap-1">
|
<Badge key={p.id} variant="outline" className="text-xs gap-1">
|
||||||
<MapPin className="h-2.5 w-2.5" />
|
<MapPin className="h-2.5 w-2.5" />
|
||||||
{p.zone ?? "No location"}
|
{placeName(p) ?? "No location"}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -196,46 +203,58 @@ function LocationView({ plants, expanded, toggle }: {
|
|||||||
expanded: Set<string>;
|
expanded: Set<string>;
|
||||||
toggle: (id: string) => void;
|
toggle: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const zones = Array.from(new Set(plants.filter((p) => p.zone).map((p) => p.zone!))).sort();
|
// Group by the shared Location (id). Plants not yet on the spine but with a
|
||||||
const noZone = plants.filter((p) => !p.zone);
|
// legacy zone string still group by that name so nothing disappears mid-migration.
|
||||||
|
type Place = { key: string; locationId: string | null; name: string; plants: Plant[] };
|
||||||
|
const byKey = new Map<string, Place>();
|
||||||
|
const noPlace: Plant[] = [];
|
||||||
|
|
||||||
|
for (const p of plants) {
|
||||||
|
const name = placeName(p);
|
||||||
|
if (!name) { noPlace.push(p); continue; }
|
||||||
|
const key = p.locationId ?? `zone:${name}`;
|
||||||
|
if (!byKey.has(key)) byKey.set(key, { key, locationId: p.locationId, name, plants: [] });
|
||||||
|
byKey.get(key)!.plants.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
const places = Array.from(byKey.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{zones.map((zone) => {
|
{places.map((place) => {
|
||||||
const zonePlants = plants.filter((p) => p.zone === zone);
|
const isOpen = expanded.has(place.key);
|
||||||
const isOpen = expanded.has(zone);
|
|
||||||
return (
|
return (
|
||||||
<div key={zone} className="space-y-2">
|
<div key={place.key} className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggle(zone)}
|
onClick={() => toggle(place.key)}
|
||||||
className="flex items-center gap-2 text-left hover:text-foreground transition-colors"
|
className="flex items-center gap-2 text-left hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
{isOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
{isOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||||
<MapPin className="h-4 w-4 text-[hsl(var(--leaf))]" />
|
<MapPin className="h-4 w-4 text-[hsl(var(--leaf))]" />
|
||||||
<span className="font-display font-semibold">{zone}</span>
|
<span className="font-display font-semibold">{place.name}</span>
|
||||||
<span className="text-sm text-muted-foreground">· {zonePlants.length} {zonePlants.length === 1 ? "plant" : "plants"}</span>
|
<span className="text-sm text-muted-foreground">· {place.plants.length} {place.plants.length === 1 ? "plant" : "plants"}</span>
|
||||||
</button>
|
</button>
|
||||||
<ZoneLogButton zone={zone} plantCount={zonePlants.length} />
|
{place.locationId && <LocationLogButton locationId={place.locationId} locationName={place.name} />}
|
||||||
</div>
|
</div>
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pl-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pl-6">
|
||||||
{zonePlants.map((p) => <PlantCard key={p.id} plant={p} />)}
|
{place.plants.map((p) => <PlantCard key={p.id} plant={p} />)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => toggle(zone)} className="pl-6 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
<button onClick={() => toggle(place.key)} className="pl-6 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||||
{zonePlants.map((p) => p.variety ? `${p.commonName} (${p.variety})` : p.commonName).join(", ")}
|
{place.plants.map((p) => p.variety ? `${p.commonName} (${p.variety})` : p.commonName).join(", ")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{noZone.length > 0 && (
|
{noPlace.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{zones.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location set</p>}
|
{places.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location set</p>}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{noZone.map((p) => <PlantCard key={p.id} plant={p} />)}
|
{noPlace.map((p) => <PlantCard key={p.id} plant={p} />)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -276,10 +295,10 @@ function PlantCard({ plant }: { plant: Plant }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1 text-xs text-muted-foreground">
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
{plant.zone && (
|
{placeName(plant) && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<MapPin className="h-3 w-3 shrink-0" />
|
<MapPin className="h-3 w-3 shrink-0" />
|
||||||
<span className="truncate">{plant.zone}</span>
|
<span className="truncate">{placeName(plant)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{plant.plantedAt && (
|
{plant.plantedAt && (
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ import { LOG_TYPE_LABELS } from "@/lib/garden/categories";
|
|||||||
|
|
||||||
const LOG_TYPES = ["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT"] as const;
|
const LOG_TYPES = ["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT"] as const;
|
||||||
|
|
||||||
export function ZoneLogButton({ zone, plantCount }: { zone: string; plantCount: number }) {
|
export function LocationLogButton({
|
||||||
|
locationId,
|
||||||
|
locationName,
|
||||||
|
}: {
|
||||||
|
locationId: string;
|
||||||
|
locationName: string;
|
||||||
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [type, setType] = useState("CARE");
|
const [type, setType] = useState("CARE");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
@@ -28,7 +34,7 @@ export function ZoneLogButton({ zone, plantCount }: { zone: string; plantCount:
|
|||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const res = await fetch(`/api/zones/${encodeURIComponent(zone)}/log`, {
|
const res = await fetch(`/api/locations/${locationId}/log`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ type, notes: notes.trim() || undefined }),
|
body: JSON.stringify({ type, notes: notes.trim() || undefined }),
|
||||||
@@ -39,7 +45,7 @@ export function ZoneLogButton({ zone, plantCount }: { zone: string; plantCount:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
toast({ title: "Logged!", description: `Added to all ${data.count} plants in ${zone}.` });
|
toast({ title: "Logged!", description: `Added to all ${data.count} plants in ${locationName}.` });
|
||||||
setNotes("");
|
setNotes("");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
@@ -60,7 +66,7 @@ export function ZoneLogButton({ zone, plantCount }: { zone: string; plantCount:
|
|||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Log for all plants in {zone}</DialogTitle>
|
<DialogTitle>Log for all plants in {locationName}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 pt-1">
|
<form onSubmit={handleSubmit} className="space-y-4 pt-1">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -8,6 +8,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.9.1",
|
||||||
date: "2026-06-15",
|
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`.
|
// 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