diff --git a/prisma/seed.ts b/prisma/seed.ts index 7ab5d83..6007262 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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") }, ]; + // 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(); + 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) { await db.plant.upsert({ where: { id: `seed-${p.commonName.toLowerCase().replace(/\s/g, "-")}-${p.variety?.toLowerCase().replace(/\s/g, "-") ?? "0"}` }, @@ -63,6 +82,7 @@ async function main() { create: { id: `seed-${p.commonName.toLowerCase().replace(/\s/g, "-")}-${p.variety?.toLowerCase().replace(/\s/g, "-") ?? "0"}`, ...p, + locationId: p.zone ? zoneToLocId.get(p.zone) : undefined, }, }); } diff --git a/src/app/(dashboard)/garden/[id]/page.tsx b/src/app/(dashboard)/garden/[id]/page.tsx index 676c20a..3e51593 100644 --- a/src/app/(dashboard)/garden/[id]/page.tsx +++ b/src/app/(dashboard)/garden/[id]/page.tsx @@ -16,18 +16,18 @@ export async function generateMetadata({ 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({ 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" } }), ]); 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 (
@@ -48,7 +48,7 @@ export default async function PlantDetailPage({ params }: { params: { id: string
- +
@@ -60,10 +60,10 @@ export default async function PlantDetailPage({ params }: { params: { id: string - {plant.zone && ( + {placeName && (
- {plant.zone} + {placeName}
)} {plant.plantedAt && ( diff --git a/src/app/(dashboard)/garden/page.tsx b/src/app/(dashboard)/garden/page.tsx index b4cebee..af2fe55 100644 --- a/src/app/(dashboard)/garden/page.tsx +++ b/src/app/(dashboard)/garden/page.tsx @@ -5,7 +5,7 @@ import { GardenGrid } from "@/components/garden/garden-grid"; export const metadata = { title: "Garden" }; export default async function GardenPage() { - const [plants, groups] = await Promise.all([ + const [plants, groups, locations] = await Promise.all([ db.plant.findMany({ where: { active: true }, orderBy: [{ commonName: "asc" }], @@ -13,6 +13,7 @@ export default async function GardenPage() { _count: { select: { logs: true } }, logs: { orderBy: { date: "desc" }, take: 1, select: { date: true, type: true } }, group: { select: { id: true, name: true } }, + location: { select: { id: true, name: true } }, }, }), db.plantGroup.findMany({ @@ -20,10 +21,13 @@ export default async function GardenPage() { orderBy: { name: "asc" }, 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 (
@@ -33,7 +37,7 @@ export default async function GardenPage() { {plants.length} {plants.length === 1 ? "plant" : "plants"} on your acre

- +
diff --git a/src/app/api/admin/backup/route.ts b/src/app/api/admin/backup/route.ts index 8f8a9db..17094db 100644 --- a/src/app/api/admin/backup/route.ts +++ b/src/app/api/admin/backup/route.ts @@ -10,8 +10,9 @@ export async function GET() { 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([ + db.location.findMany(), db.user.findMany(), db.plantGroup.findMany(), db.plant.findMany(), @@ -24,11 +25,12 @@ export async function GET() { const manifest = { version: 1, exportedAt: new Date().toISOString(), - tables: ["users", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "auditEvents"], + tables: ["locations", "users", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "auditEvents"], }; const zip = zipSync({ "manifest.json": strToU8(JSON.stringify(manifest, null, 2)), + "locations.json": strToU8(JSON.stringify(locations, null, 2)), "users.json": strToU8(JSON.stringify(users, null, 2)), "plantGroups.json": strToU8(JSON.stringify(plantGroups, null, 2)), "plants.json": strToU8(JSON.stringify(plants, null, 2)), diff --git a/src/app/api/admin/restore/route.ts b/src/app/api/admin/restore/route.ts index f1483f7..09bc27c 100644 --- a/src/app/api/admin/restore/route.ts +++ b/src/app/api/admin/restore/route.ts @@ -27,6 +27,8 @@ export async function POST(req: Request) { 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 plantGroups = parse("plantGroups.json"); const plants = parse("plants.json"); @@ -35,17 +37,21 @@ export async function POST(req: Request) { const taskCompletions = parse("taskCompletions.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 tx.taskCompletion.deleteMany(); await tx.plantLog.deleteMany(); await tx.task.deleteMany(); await tx.plant.deleteMany(); await tx.plantGroup.deleteMany(); + await tx.location.deleteMany(); await tx.auditEvent.deleteMany(); await tx.user.deleteMany(); 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 (plants.length) await tx.plant.createMany({ data: plants }); if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs }); diff --git a/src/app/api/locations/[id]/log/route.ts b/src/app/api/locations/[id]/log/route.ts new file mode 100644 index 0000000..b18c938 --- /dev/null +++ b/src/app/api/locations/[id]/log/route.ts @@ -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 }); + } +} diff --git a/src/app/api/locations/[id]/route.ts b/src/app/api/locations/[id]/route.ts new file mode 100644 index 0000000..55955a7 --- /dev/null +++ b/src/app/api/locations/[id]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/locations/route.ts b/src/app/api/locations/route.ts new file mode 100644 index 0000000..4242225 --- /dev/null +++ b/src/app/api/locations/route.ts @@ -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 }); + } +} diff --git a/src/app/api/plants/[id]/route.ts b/src/app/api/plants/[id]/route.ts index b456cd8..f891707 100644 --- a/src/app/api/plants/[id]/route.ts +++ b/src/app/api/plants/[id]/route.ts @@ -10,12 +10,23 @@ const updateSchema = z.object({ variety: z.string().optional(), category: z.string().min(1), zone: z.string().optional(), + locationId: z.string().nullable().optional(), plantedAt: z.string().optional(), source: z.string().optional(), notes: z.string().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 { + 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 } }) { try { const session = await requireAuth(); @@ -35,6 +46,8 @@ export async function PATCH(req: Request, { params }: { params: { id: string } } ? await fetchPlantImageUrl(species, commonName) : undefined; + const zone = await resolveZone(data.locationId, data.zone); + const updated = await db.plant.update({ where: { id: params.id }, data: { @@ -42,7 +55,8 @@ export async function PATCH(req: Request, { params }: { params: { id: string } } species, variety: data.variety?.trim() || null, 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, source: data.source?.trim() || null, notes: data.notes?.trim() || null, diff --git a/src/app/api/plants/route.ts b/src/app/api/plants/route.ts index d586350..6c29212 100644 --- a/src/app/api/plants/route.ts +++ b/src/app/api/plants/route.ts @@ -10,12 +10,24 @@ const createSchema = z.object({ variety: z.string().optional(), category: z.string().min(1), zone: z.string().optional(), + locationId: z.string().optional(), plantedAt: z.string().optional(), source: z.string().optional(), notes: 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 { + 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) { try { const session = await requireAuth(); @@ -25,6 +37,7 @@ export async function POST(req: Request) { const species = data.species?.trim() || null; const commonName = data.commonName.trim(); const imageUrl = await fetchPlantImageUrl(species, commonName); + const zone = await resolveZone(data.locationId, data.zone); const plant = await db.plant.create({ data: { @@ -32,7 +45,8 @@ export async function POST(req: Request) { species, variety: data.variety?.trim() || null, 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, source: data.source?.trim() || null, notes: data.notes?.trim() || null, diff --git a/src/components/garden/add-plant-button.tsx b/src/components/garden/add-plant-button.tsx index 21337bd..430f491 100644 --- a/src/components/garden/add-plant-button.tsx +++ b/src/components/garden/add-plant-button.tsx @@ -20,6 +20,7 @@ import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories"; import { searchPlants, type PlantSuggestion } from "@/lib/garden/plant-db"; type Group = { id: string; name: string }; +type LocationOption = { id: string; name: string }; const BLANK = { commonName: "", @@ -33,12 +34,19 @@ const BLANK = { 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 [fields, setFields] = useState(BLANK); - const [selectedZones, setSelectedZones] = useState([]); - const [newZoneName, setNewZoneName] = useState(""); - const [addingZone, setAddingZone] = useState(false); + const [locationOptions, setLocationOptions] = useState(locations); + const [selectedLocationIds, setSelectedLocationIds] = useState([]); + const [newLocationName, setNewLocationName] = useState(""); + const [addingLocation, setAddingLocation] = useState(false); const [creatingGroup, setCreatingGroup] = useState(false); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); @@ -47,25 +55,46 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[]; const router = useRouter(); 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) { setFields((f) => ({ ...f, [key]: value })); } - function toggleZone(zone: string) { - setSelectedZones((prev) => - prev.includes(zone) ? prev.filter((z) => z !== zone) : [...prev, zone] + function nameFor(id: string) { + return locationOptions.find((l) => l.id === id)?.name ?? "Location"; + } + + function toggleLocation(id: string) { + setSelectedLocationIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] ); } - function addNewZone() { - const name = newZoneName.trim(); + // Persist new locations to the shared spine immediately so the plant gets a + // real locationId (the freeform "zone" string is dual-written server-side). + async function addNewLocation() { + const name = newLocationName.trim(); if (!name) return; - if (!selectedZones.includes(name)) setSelectedZones((prev) => [...prev, name]); - setNewZoneName(""); - setAddingZone(false); + const existing = locationOptions.find((l) => l.name.toLowerCase() === name.toLowerCase()); + if (existing) { + 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) { @@ -90,9 +119,9 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[]; function handleClose() { setOpen(false); setFields(BLANK); - setSelectedZones([]); - setNewZoneName(""); - setAddingZone(false); + setSelectedLocationIds([]); + setNewLocationName(""); + setAddingLocation(false); setError(""); setSuggestions([]); setShowSuggestions(false); @@ -130,14 +159,14 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[]; groupId, }; - // Create one plant per selected zone (or one with no zone if none selected). - const zonesToCreate = selectedZones.length > 0 ? selectedZones : [undefined]; + // Create one plant per selected location (or one with no location if none). + const locsToCreate = selectedLocationIds.length > 0 ? selectedLocationIds : [undefined]; const results = await Promise.all( - zonesToCreate.map((zone) => + locsToCreate.map((locationId) => fetch("/api/plants", { method: "POST", 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; } - const count = zonesToCreate.length; + const count = locsToCreate.length; toast({ title: "Plant added!", description: count > 1 @@ -254,23 +283,23 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[]; /> - {/* Multi-location picker */} + {/* Multi-location picker (shared Location spine) */}
- {/* Selected zones as chips */} - {selectedZones.length > 0 && ( + {/* Selected locations as chips */} + {selectedLocationIds.length > 0 && (
- {selectedZones.map((z) => ( - + {selectedLocationIds.map((id) => ( + - {z} + {nameFor(id)}
)} - {/* Existing zones as checkboxes */} - {allZones.length > 0 && ( + {/* Existing locations as checkboxes */} + {locationOptions.length > 0 && (
- {allZones.map((z) => ( + {locationOptions.map((loc) => ( ))}
)} - {/* Add new zone */} - {addingZone ? ( + {/* Add new location */} + {addingLocation ? (
setNewZoneName(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewZone(); } }} + value={newLocationName} + onChange={(e) => setNewLocationName(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewLocation(); } }} autoFocus /> - - +
) : ( diff --git a/src/components/garden/edit-plant-button.tsx b/src/components/garden/edit-plant-button.tsx index 4ce2f60..2deaf19 100644 --- a/src/components/garden/edit-plant-button.tsx +++ b/src/components/garden/edit-plant-button.tsx @@ -20,12 +20,14 @@ import { useToast } from "@/hooks/use-toast"; import { Pencil } from "lucide-react"; import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories"; +type LocationOption = { id: string; name: string }; + const schema = z.object({ commonName: z.string().min(1, "Name is required"), species: z.string().optional(), variety: z.string().optional(), category: z.nativeEnum(PlantCategory), - zone: z.string().optional(), + locationId: z.string().optional(), plantedAt: z.string().optional(), source: 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]; } -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 [creatingZone, setCreatingZone] = useState(false); - const [newZoneName, setNewZoneName] = useState(""); + const [locationOptions, setLocationOptions] = useState(locations); + const [creatingLocation, setCreatingLocation] = useState(false); + const [newLocationName, setNewLocationName] = useState(""); const router = useRouter(); const { toast } = useToast(); @@ -51,19 +62,44 @@ export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Pla species: plant.species ?? "", variety: plant.variety ?? "", category: plant.category, - zone: plant.zone ?? "", + locationId: plant.locationId ?? "", plantedAt: toDateInput(plant.plantedAt), source: plant.source ?? "", 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) { - if (newZoneName.trim()) values.zone = newZoneName.trim(); const res = await fetch(`/api/plants/${plant.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(values), + body: JSON.stringify({ ...values, locationId: values.locationId || null }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); @@ -127,12 +163,12 @@ export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Pla
- {!creatingZone ? ( + {!creatingLocation ? ( setNewZoneName(e.target.value)} + value={newLocationName} + onChange={(e) => setNewLocationName(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewLocation(); } }} autoFocus /> - +
diff --git a/src/components/garden/garden-grid.tsx b/src/components/garden/garden-grid.tsx index 0da098c..2b304f9 100644 --- a/src/components/garden/garden-grid.tsx +++ b/src/components/garden/garden-grid.tsx @@ -9,7 +9,7 @@ import { Leaf, MapPin, CalendarDays, ChevronDown, ChevronRight, Users } from "lu import { formatDate } from "@/lib/utils"; import { CATEGORY_LABELS } from "@/lib/garden/categories"; 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 = { id: string; @@ -18,6 +18,8 @@ type Plant = { species: string | null; category: string; zone: string | null; + locationId: string | null; + location: { id: string; name: string } | null; plantedAt: Date | null; imageUrl: string | null; group: { id: string; name: string } | null; @@ -25,6 +27,11 @@ type Plant = { 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 = { id: string; name: string; @@ -174,7 +181,7 @@ function VarietyRow({ plants, expanded, toggle }: { {plants.map((p) => ( - {p.zone ?? "No location"} + {placeName(p) ?? "No location"} ))}
@@ -196,46 +203,58 @@ function LocationView({ plants, expanded, toggle }: { expanded: Set; toggle: (id: string) => void; }) { - const zones = Array.from(new Set(plants.filter((p) => p.zone).map((p) => p.zone!))).sort(); - const noZone = plants.filter((p) => !p.zone); + // Group by the shared Location (id). Plants not yet on the spine but with a + // 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(); + 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 (
- {zones.map((zone) => { - const zonePlants = plants.filter((p) => p.zone === zone); - const isOpen = expanded.has(zone); + {places.map((place) => { + const isOpen = expanded.has(place.key); return ( -
+
- + {place.locationId && }
{isOpen ? (
- {zonePlants.map((p) => )} + {place.plants.map((p) => )}
) : ( - )}
); })} - {noZone.length > 0 && ( + {noPlace.length > 0 && (
- {zones.length > 0 &&

No location set

} + {places.length > 0 &&

No location set

}
- {noZone.map((p) => )} + {noPlace.map((p) => )}
)} @@ -276,10 +295,10 @@ function PlantCard({ plant }: { plant: Plant }) {
- {plant.zone && ( + {placeName(plant) && (
- {plant.zone} + {placeName(plant)}
)} {plant.plantedAt && ( diff --git a/src/components/garden/zone-log-button.tsx b/src/components/garden/location-log-button.tsx similarity index 91% rename from src/components/garden/zone-log-button.tsx rename to src/components/garden/location-log-button.tsx index c02f7d6..f0af3de 100644 --- a/src/components/garden/zone-log-button.tsx +++ b/src/components/garden/location-log-button.tsx @@ -17,7 +17,13 @@ import { LOG_TYPE_LABELS } from "@/lib/garden/categories"; 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 [type, setType] = useState("CARE"); const [notes, setNotes] = useState(""); @@ -28,7 +34,7 @@ export function ZoneLogButton({ zone, plantCount }: { zone: string; plantCount: async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setBusy(true); - const res = await fetch(`/api/zones/${encodeURIComponent(zone)}/log`, { + const res = await fetch(`/api/locations/${locationId}/log`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type, notes: notes.trim() || undefined }), @@ -39,7 +45,7 @@ export function ZoneLogButton({ zone, plantCount }: { zone: string; plantCount: return; } 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(""); setOpen(false); router.refresh(); @@ -60,7 +66,7 @@ export function ZoneLogButton({ zone, plantCount }: { zone: string; plantCount: - Log for all plants in {zone} + Log for all plants in {locationName}
diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index c3a81d8..1af6eea 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -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", diff --git a/src/lib/locations/db.ts b/src/lib/locations/db.ts new file mode 100644 index 0000000..bba5639 --- /dev/null +++ b/src/lib/locations/db.ts @@ -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 { + 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 { + 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); +} diff --git a/src/lib/locations/tree.test.ts b/src/lib/locations/tree.test.ts new file mode 100644 index 0000000..05072bd --- /dev/null +++ b/src/lib/locations/tree.test.ts @@ -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).children).toBeUndefined(); + }); +}); diff --git a/src/lib/locations/tree.ts b/src/lib/locations/tree.ts new file mode 100644 index 0000000..3d52685 --- /dev/null +++ b/src/lib/locations/tree.ts @@ -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 & { + children: TreeNode[]; + 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(rows: T[]): TreeNode[] { + const byId = new Map>(); + for (const r of rows) byId.set(r.id, { ...r, children: [], depth: 0 }); + + const roots: TreeNode[] = []; + 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[], 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(rows: T[]): Map { + 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( + id: string, + byId: Map, + sep: string = PATH_SEP, +): string { + const names: string[] = []; + const seen = new Set(); + 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( + id: string, + rows: T[], + includeSelf = false, +): string[] { + const childrenByParent = new Map(); + 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([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