diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
index 59c4876..1f48567 100644
--- a/docker-entrypoint.sh
+++ b/docker-entrypoint.sh
@@ -14,4 +14,7 @@ echo "Running Prisma migrations..."
echo "Running Location-spine backfill (idempotent)..."
./node_modules/.bin/tsx prisma/scripts/backfill-locations.ts || echo " Backfill skipped/failed (non-fatal); check logs."
+echo "Running plant-type backfill (idempotent)..."
+./node_modules/.bin/tsx prisma/scripts/backfill-plant-types.ts || echo " Backfill skipped/failed (non-fatal); check logs."
+
exec "$@"
diff --git a/package.json b/package.json
index 59577b3..7b54d08 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"db:migrate:deploy": "prisma migrate deploy",
"db:seed": "tsx prisma/seed.ts",
"db:backfill-locations": "tsx prisma/scripts/backfill-locations.ts",
+ "db:backfill-plant-types": "tsx prisma/scripts/backfill-plant-types.ts",
"db:studio": "prisma studio",
"db:reset": "prisma migrate reset --force",
"db:backup": "sh scripts/db-backup.sh",
diff --git a/prisma/migrations/20260616000001_v0_11_plant_types/migration.sql b/prisma/migrations/20260616000001_v0_11_plant_types/migration.sql
new file mode 100644
index 0000000..d83df37
--- /dev/null
+++ b/prisma/migrations/20260616000001_v0_11_plant_types/migration.sql
@@ -0,0 +1,42 @@
+-- v0.11.0 — Plant types (the "kind" record)
+-- A rich-but-all-optional reference record per kind of plant, plus an optional
+-- link from each planting. Seeded/linked by prisma/scripts/backfill-plant-types.ts.
+
+-- CreateTable
+CREATE TABLE "PlantType" (
+ "id" TEXT NOT NULL,
+ "commonName" TEXT NOT NULL,
+ "species" TEXT,
+ "category" "PlantCategory" NOT NULL DEFAULT 'OTHER',
+ "careNotes" TEXT,
+ "sun" TEXT,
+ "water" TEXT,
+ "bloomStart" INTEGER,
+ "bloomEnd" INTEGER,
+ "harvestStart" INTEGER,
+ "harvestEnd" INTEGER,
+ "toxicToPets" BOOLEAN,
+ "edibleParts" TEXT,
+ "spacing" TEXT,
+ "imageUrl" TEXT,
+ "notes" TEXT,
+ "active" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "PlantType_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "PlantType_commonName_idx" ON "PlantType"("commonName");
+CREATE INDEX "PlantType_category_idx" ON "PlantType"("category");
+CREATE INDEX "PlantType_active_idx" ON "PlantType"("active");
+
+-- AlterTable: optional link from a planting to its kind
+ALTER TABLE "Plant" ADD COLUMN "plantTypeId" TEXT;
+
+-- CreateIndex
+CREATE INDEX "Plant_plantTypeId_idx" ON "Plant"("plantTypeId");
+
+-- AddForeignKey
+ALTER TABLE "Plant" ADD CONSTRAINT "Plant_plantTypeId_fkey" FOREIGN KEY ("plantTypeId") REFERENCES "PlantType"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index d889439..19354a2 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -126,6 +126,8 @@ model Plant {
zone String? // legacy freeform location — kept during the Location migration (dropped in Release B)
locationId String? // shared Location spine
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
+ plantTypeId String? // optional link to the "kind" reference record
+ plantType PlantType? @relation(fields: [plantTypeId], references: [id], onDelete: SetNull)
plantedAt DateTime? // when it went in the ground
source String? // where it came from ("Jung's Nursery", "from seed", "division from Mom's")
notes String? // general notes about this plant
@@ -141,10 +143,42 @@ model Plant {
@@index([category])
@@index([zone])
@@index([locationId])
+ @@index([plantTypeId])
@@index([active])
@@index([groupId])
}
+// PlantType — the "kind" of plant (e.g. "Lily of the valley"), holding general
+// reusable reference info. Rich but every field optional; seeded from the
+// built-in plant library (src/lib/garden/plant-db.ts). Individual Plant rows
+// optionally point at their type.
+model PlantType {
+ id String @id @default(cuid())
+ commonName String
+ species String? // scientific name
+ category PlantCategory @default(OTHER)
+ careNotes String? // general care: pruning, watering, feeding
+ sun String? // "Full sun", "Part shade", "Shade"
+ water String? // "Low", "Moderate", "High"
+ bloomStart Int? // month 1–12
+ bloomEnd Int? // month 1–12
+ harvestStart Int? // month 1–12
+ harvestEnd Int? // month 1–12
+ toxicToPets Boolean? // null = unknown
+ edibleParts String? // "Fruit, young leaves"
+ spacing String? // "10–15 ft", "18 in"
+ imageUrl String?
+ notes String?
+ active Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ plants Plant[]
+
+ @@index([commonName])
+ @@index([category])
+ @@index([active])
+}
+
enum PlantLogType {
OBSERVATION // general note — "looking healthy", "saw first flowers"
CARE // watering, mulching, pruning, fertilizing, chop-and-drop
diff --git a/prisma/scripts/backfill-plant-types.ts b/prisma/scripts/backfill-plant-types.ts
new file mode 100644
index 0000000..56b8253
--- /dev/null
+++ b/prisma/scripts/backfill-plant-types.ts
@@ -0,0 +1,23 @@
+/**
+ * v0.11.0 Plant-type backfill. Seeds the PlantType library from the built-in
+ * plant DB and links existing plantings to their kind. Idempotent — runs on
+ * deploy (docker-entrypoint.sh) and can be run manually:
+ *
+ * npx tsx prisma/scripts/backfill-plant-types.ts
+ */
+import { PrismaClient } from "@prisma/client";
+import { syncPlantTypesFromLibrary } from "../../src/lib/garden/plant-types";
+
+const db = new PrismaClient();
+
+async function main() {
+ const r = await syncPlantTypesFromLibrary(db);
+ console.log(`Plant types: library has ${r.types} kinds; linked ${r.linked} planting(s).`);
+}
+
+main()
+ .catch((e) => {
+ console.error("Plant-type backfill failed:", e);
+ process.exit(1);
+ })
+ .finally(() => db.$disconnect());
diff --git a/prisma/seed.ts b/prisma/seed.ts
index 6007262..a753fe9 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -1,5 +1,6 @@
import { PrismaClient, PlantCategory } from "@prisma/client";
import { hashSync } from "bcryptjs";
+import { syncPlantTypesFromLibrary } from "../src/lib/garden/plant-types";
const db = new PrismaClient();
@@ -87,6 +88,10 @@ async function main() {
});
}
+ // Seed the PlantType library and link the sample plants to their kind.
+ const pt = await syncPlantTypesFromLibrary(db);
+ console.log(`Seeded ${pt.types} plant types; linked ${pt.linked} plantings.`);
+
console.log("Demo seed complete.");
}
diff --git a/src/app/(dashboard)/garden/[id]/page.tsx b/src/app/(dashboard)/garden/[id]/page.tsx
index 3e51593..cf7cec1 100644
--- a/src/app/(dashboard)/garden/[id]/page.tsx
+++ b/src/app/(dashboard)/garden/[id]/page.tsx
@@ -3,7 +3,7 @@ import { db } from "@/lib/db";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { MapPin, CalendarDays, ChevronLeft, Sprout, Package } from "lucide-react";
+import { MapPin, CalendarDays, ChevronLeft, Sprout, Package, BookOpen } from "lucide-react";
import { formatDate } from "@/lib/utils";
import { CATEGORY_LABELS, LOG_TYPE_LABELS, LOG_TYPE_COLORS } from "@/lib/garden/categories";
import { AddLogButton } from "@/components/garden/add-log-button";
@@ -19,7 +19,11 @@ export default async function PlantDetailPage({ params }: { params: { id: string
const [plant, locations, groups] = await Promise.all([
db.plant.findUnique({
where: { id: params.id },
- include: { logs: { orderBy: { date: "desc" } }, location: { select: { id: true, name: true } } },
+ include: {
+ logs: { orderBy: { date: "desc" } },
+ location: { select: { id: true, name: true } },
+ plantType: { select: { id: true, commonName: 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" } }),
@@ -66,6 +70,14 @@ export default async function PlantDetailPage({ params }: { params: { id: string
{placeName}
)}
+ {plant.plantType && (
+
diff --git a/src/app/(dashboard)/garden/types/[id]/page.tsx b/src/app/(dashboard)/garden/types/[id]/page.tsx
new file mode 100644
index 0000000..a538e57
--- /dev/null
+++ b/src/app/(dashboard)/garden/types/[id]/page.tsx
@@ -0,0 +1,163 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { db } from "@/lib/db";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ ChevronLeft, Sun, Droplets, Flower2, Apple, AlertTriangle, Ruler, MapPin, Leaf,
+} from "lucide-react";
+import { CATEGORY_LABELS } from "@/lib/garden/categories";
+import { EditPlantTypeButton, type PlantTypeFields } from "@/components/garden/plant-type-dialog";
+import { DeletePlantTypeButton } from "@/components/garden/delete-plant-type-button";
+
+const MONTHS = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
+
+function monthRange(start: number | null, end: number | null): string | null {
+ if (start && end) return `${MONTHS[start]}–${MONTHS[end]}`;
+ if (start) return `from ${MONTHS[start]}`;
+ if (end) return `through ${MONTHS[end]}`;
+ return null;
+}
+
+export async function generateMetadata({ params }: { params: { id: string } }) {
+ const t = await db.plantType.findUnique({ where: { id: params.id }, select: { commonName: true } });
+ return { title: t?.commonName ?? "Plant type" };
+}
+
+export default async function PlantTypeDetailPage({ params }: { params: { id: string } }) {
+ const type = await db.plantType.findUnique({
+ where: { id: params.id },
+ include: {
+ plants: {
+ where: { active: true },
+ orderBy: { commonName: "asc" },
+ include: { location: { select: { name: true } } },
+ },
+ },
+ });
+
+ if (!type || !type.active) notFound();
+
+ const bloom = monthRange(type.bloomStart, type.bloomEnd);
+ const harvest = monthRange(type.harvestStart, type.harvestEnd);
+
+ const formFields: PlantTypeFields = {
+ id: type.id,
+ commonName: type.commonName,
+ species: type.species,
+ category: type.category,
+ careNotes: type.careNotes,
+ sun: type.sun,
+ water: type.water,
+ bloomStart: type.bloomStart,
+ bloomEnd: type.bloomEnd,
+ harvestStart: type.harvestStart,
+ harvestEnd: type.harvestEnd,
+ toxicToPets: type.toxicToPets,
+ edibleParts: type.edibleParts,
+ spacing: type.spacing,
+ notes: type.notes,
+ };
+
+ const facts: { icon: React.ReactNode; label: string; value: string }[] = [];
+ if (type.sun) facts.push({ icon:
, label: "Sun", value: type.sun });
+ if (type.water) facts.push({ icon:
, label: "Water", value: type.water });
+ if (bloom) facts.push({ icon:
, label: "Blooms", value: bloom });
+ if (harvest) facts.push({ icon:
, label: "Harvest", value: harvest });
+ if (type.edibleParts) facts.push({ icon:
, label: "Edible", value: type.edibleParts });
+ if (type.spacing) facts.push({ icon:
, label: "Spacing", value: type.spacing });
+
+ return (
+
+
+
+
+
+
+
{type.commonName}
+ {type.species &&
{type.species}
}
+
+
{CATEGORY_LABELS[type.category]}
+
+
+
+
+
+
+
+ {type.toxicToPets && (
+
+
+ Toxic to pets — keep away from animals.
+
+ )}
+ {type.toxicToPets === false && (
+
+ ✓ Safe for pets.
+
+ )}
+
+ {facts.length > 0 && (
+
+
+ {facts.map((fct, i) => (
+
+
{fct.icon}
+
+
{fct.label}
+
{fct.value}
+
+
+ ))}
+
+
+ )}
+
+ {(type.careNotes || type.notes) && (
+
+
+ {type.careNotes && (
+
+
Care
+
{type.careNotes}
+
+ )}
+ {type.notes && (
+
+ )}
+
+
+ )}
+
+
+
+ Your plantings {type.plants.length > 0 && · {type.plants.length} }
+
+ {type.plants.length === 0 ? (
+
+ None planted yet — this is general info you can keep regardless.
+
+ ) : (
+
+ {type.plants.map((p) => (
+
+
+
+ {p.commonName}{p.variety && · {p.variety} }
+
+ {p.location?.name && (
+
+ {p.location.name}
+
+ )}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(dashboard)/garden/types/page.tsx b/src/app/(dashboard)/garden/types/page.tsx
new file mode 100644
index 0000000..d96459b
--- /dev/null
+++ b/src/app/(dashboard)/garden/types/page.tsx
@@ -0,0 +1,77 @@
+import Link from "next/link";
+import { db } from "@/lib/db";
+import { Badge } from "@/components/ui/badge";
+import { BookOpen, ChevronRight, Leaf, AlertTriangle } from "lucide-react";
+import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
+import { AddPlantTypeButton } from "@/components/garden/plant-type-dialog";
+
+export const metadata = { title: "Plant types" };
+
+export default async function PlantTypesPage() {
+ const types = await db.plantType.findMany({
+ where: { active: true },
+ orderBy: { commonName: "asc" },
+ include: { _count: { select: { plants: { where: { active: true } } } } },
+ });
+
+ const byCategory = CATEGORY_ORDER.map((cat) => ({
+ category: cat,
+ types: types.filter((t) => t.category === cat),
+ })).filter((g) => g.types.length > 0);
+
+ return (
+
+
+
+
Plant types
+
+ General knowledge about each kind of plant — care, sun, bloom & harvest, toxicity.
+ {" "}{types.length} {types.length === 1 ? "kind" : "kinds"} in your library.
+
+
+
+
+
+ {types.length === 0 ? (
+
+
+
No plant types yet
+
Add one, or they'll appear here as you add plants.
+
+ ) : (
+
+ {byCategory.map((group) => (
+
+
+ {CATEGORY_LABELS[group.category]}
+
+
+ {group.types.map((t) => (
+
+
+
+
{t.commonName}
+ {t.species &&
{t.species}
}
+
+ {t.toxicToPets && (
+
+ Toxic
+
+ )}
+ {t._count.plants > 0 && (
+
+ {t._count.plants} planted
+
+ )}
+
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/api/admin/backup/route.ts b/src/app/api/admin/backup/route.ts
index 17094db..24b4188 100644
--- a/src/app/api/admin/backup/route.ts
+++ b/src/app/api/admin/backup/route.ts
@@ -10,9 +10,10 @@ export async function GET() {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
- const [locations, users, plantGroups, plants, plantLogs, tasks, taskCompletions, auditEvents] =
+ const [locations, plantTypes, users, plantGroups, plants, plantLogs, tasks, taskCompletions, auditEvents] =
await Promise.all([
db.location.findMany(),
+ db.plantType.findMany(),
db.user.findMany(),
db.plantGroup.findMany(),
db.plant.findMany(),
@@ -25,12 +26,13 @@ export async function GET() {
const manifest = {
version: 1,
exportedAt: new Date().toISOString(),
- tables: ["locations", "users", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "auditEvents"],
+ tables: ["locations", "plantTypes", "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)),
+ "plantTypes.json": strToU8(JSON.stringify(plantTypes, 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 09bc27c..1568a4a 100644
--- a/src/app/api/admin/restore/route.ts
+++ b/src/app/api/admin/restore/route.ts
@@ -27,8 +27,9 @@ 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.
+ // Older backups won't have newer tables — tolerate their absence.
const locations = files["locations.json"] ? parse("locations.json") : [];
+ const plantTypes = files["plantTypes.json"] ? parse("plantTypes.json") : [];
const users = parse("users.json");
const plantGroups = parse("plantGroups.json");
const plants = parse("plants.json");
@@ -44,6 +45,7 @@ export async function POST(req: Request) {
await tx.task.deleteMany();
await tx.plant.deleteMany();
await tx.plantGroup.deleteMany();
+ await tx.plantType.deleteMany();
await tx.location.deleteMany();
await tx.auditEvent.deleteMany();
await tx.user.deleteMany();
@@ -52,6 +54,7 @@ export async function POST(req: Request) {
// 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 (plantTypes.length) await tx.plantType.createMany({ data: plantTypes });
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/plant-types/[id]/route.ts b/src/app/api/plant-types/[id]/route.ts
new file mode 100644
index 0000000..78a6eb2
--- /dev/null
+++ b/src/app/api/plant-types/[id]/route.ts
@@ -0,0 +1,104 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { PlantCategory, Prisma } from "@prisma/client";
+import { db } from "@/lib/db";
+import { requireAuth, isAdmin } from "@/lib/auth";
+
+const month = z.coerce.number().int().min(1).max(12).nullable();
+
+// All optional — a PATCH only touches the fields it sends. Empty string clears
+// a text field; null clears a numeric/boolean one.
+const updateSchema = z.object({
+ commonName: z.string().min(1).optional(),
+ species: z.string().optional(),
+ category: z.nativeEnum(PlantCategory).optional(),
+ careNotes: z.string().optional(),
+ sun: z.string().optional(),
+ water: z.string().optional(),
+ bloomStart: month.optional(),
+ bloomEnd: month.optional(),
+ harvestStart: month.optional(),
+ harvestEnd: month.optional(),
+ toxicToPets: z.boolean().nullable().optional(),
+ edibleParts: z.string().optional(),
+ spacing: z.string().optional(),
+ imageUrl: z.string().optional(),
+ notes: z.string().optional(),
+});
+
+const TEXT_FIELDS = [
+ "species", "careNotes", "sun", "water", "edibleParts", "spacing", "imageUrl", "notes",
+] as const;
+
+export async function PATCH(req: Request, { params }: { params: { id: string } }) {
+ try {
+ const session = await requireAuth();
+ const existing = await db.plantType.findUnique({ where: { id: params.id } });
+ if (!existing || !existing.active)
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const body = await req.json();
+ const data = updateSchema.parse(body);
+
+ const update: Prisma.PlantTypeUpdateInput = {};
+ if (data.commonName !== undefined) update.commonName = data.commonName.trim();
+ if (data.category !== undefined) update.category = data.category;
+ if (data.toxicToPets !== undefined) update.toxicToPets = data.toxicToPets;
+ for (const k of ["bloomStart", "bloomEnd", "harvestStart", "harvestEnd"] as const) {
+ if (data[k] !== undefined) update[k] = data[k];
+ }
+ for (const k of TEXT_FIELDS) {
+ if (data[k] !== undefined) update[k] = data[k]?.trim() || null;
+ }
+
+ const updated = await db.plantType.update({ where: { id: params.id }, data: update });
+
+ await db.auditEvent.create({
+ data: {
+ action: "plant_type.update",
+ entityId: params.id,
+ actorId: session.user.id,
+ payload: { commonName: updated.commonName },
+ },
+ });
+
+ 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 existing = await db.plantType.findUnique({ where: { id: params.id } });
+ if (!existing || !existing.active)
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ // Soft-delete; plantings keep their plantTypeId (the row just goes inactive).
+ await db.plantType.update({ where: { id: params.id }, data: { active: false } });
+ await db.auditEvent.create({
+ data: {
+ action: "plant_type.delete",
+ entityId: params.id,
+ actorId: session.user.id,
+ payload: { commonName: existing.commonName },
+ },
+ });
+
+ 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/plant-types/route.ts b/src/app/api/plant-types/route.ts
new file mode 100644
index 0000000..00caad2
--- /dev/null
+++ b/src/app/api/plant-types/route.ts
@@ -0,0 +1,91 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { PlantCategory } from "@prisma/client";
+import { db } from "@/lib/db";
+import { requireAuth } from "@/lib/auth";
+
+const month = z.coerce.number().int().min(1).max(12);
+
+const createSchema = z.object({
+ commonName: z.string().min(1),
+ species: z.string().optional(),
+ category: z.nativeEnum(PlantCategory).optional(),
+ careNotes: z.string().optional(),
+ sun: z.string().optional(),
+ water: z.string().optional(),
+ bloomStart: month.optional(),
+ bloomEnd: month.optional(),
+ harvestStart: month.optional(),
+ harvestEnd: month.optional(),
+ toxicToPets: z.boolean().optional(),
+ edibleParts: z.string().optional(),
+ spacing: z.string().optional(),
+ imageUrl: z.string().optional(),
+ notes: z.string().optional(),
+});
+
+// GET /api/plant-types — list active kinds with a planting count.
+export async function GET() {
+ try {
+ await requireAuth();
+ const types = await db.plantType.findMany({
+ where: { active: true },
+ orderBy: { commonName: "asc" },
+ include: { _count: { select: { plants: { where: { active: true } } } } },
+ });
+ return NextResponse.json(types);
+ } 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/plant-types — create a kind.
+export async function POST(req: Request) {
+ try {
+ const session = await requireAuth();
+ const data = createSchema.parse(await req.json());
+ const commonName = data.commonName.trim();
+ if (!commonName) return NextResponse.json({ error: "Name is required" }, { status: 400 });
+
+ const created = await db.plantType.create({
+ data: {
+ commonName,
+ species: data.species?.trim() || null,
+ category: data.category ?? PlantCategory.OTHER,
+ careNotes: data.careNotes?.trim() || null,
+ sun: data.sun?.trim() || null,
+ water: data.water?.trim() || null,
+ bloomStart: data.bloomStart ?? null,
+ bloomEnd: data.bloomEnd ?? null,
+ harvestStart: data.harvestStart ?? null,
+ harvestEnd: data.harvestEnd ?? null,
+ toxicToPets: data.toxicToPets ?? null,
+ edibleParts: data.edibleParts?.trim() || null,
+ spacing: data.spacing?.trim() || null,
+ imageUrl: data.imageUrl?.trim() || null,
+ notes: data.notes?.trim() || null,
+ },
+ });
+
+ await db.auditEvent.create({
+ data: {
+ action: "plant_type.create",
+ entityId: created.id,
+ actorId: session.user.id,
+ payload: { commonName: created.commonName, category: created.category },
+ },
+ });
+
+ return NextResponse.json(created, { 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/route.ts b/src/app/api/plants/route.ts
index 6c29212..8caa3a7 100644
--- a/src/app/api/plants/route.ts
+++ b/src/app/api/plants/route.ts
@@ -28,6 +28,22 @@ async function resolveZone(locationId?: string, zone?: string): Promise
{
+ if (species) {
+ const bySpecies = await db.plantType.findFirst({
+ where: { active: true, species: { equals: species, mode: "insensitive" } },
+ select: { id: true },
+ });
+ if (bySpecies) return bySpecies.id;
+ }
+ const byCommon = await db.plantType.findFirst({
+ where: { active: true, commonName: { equals: commonName, mode: "insensitive" } },
+ select: { id: true },
+ });
+ return byCommon?.id ?? null;
+}
+
export async function POST(req: Request) {
try {
const session = await requireAuth();
@@ -38,6 +54,7 @@ export async function POST(req: Request) {
const commonName = data.commonName.trim();
const imageUrl = await fetchPlantImageUrl(species, commonName);
const zone = await resolveZone(data.locationId, data.zone);
+ const plantTypeId = await resolvePlantTypeId(species, commonName);
const plant = await db.plant.create({
data: {
@@ -47,6 +64,7 @@ export async function POST(req: Request) {
category: data.category as PlantCategory,
zone, // dual-write: kept in sync with location.name until Release B
locationId: data.locationId || null,
+ plantTypeId,
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
source: data.source?.trim() || null,
notes: data.notes?.trim() || null,
diff --git a/src/components/garden/delete-plant-type-button.tsx b/src/components/garden/delete-plant-type-button.tsx
new file mode 100644
index 0000000..78b01b3
--- /dev/null
+++ b/src/components/garden/delete-plant-type-button.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
+} from "@/components/ui/dialog";
+import { useToast } from "@/hooks/use-toast";
+import { Trash2 } from "lucide-react";
+
+export function DeletePlantTypeButton({ id, name }: { id: string; name: string }) {
+ const [open, setOpen] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const router = useRouter();
+ const { toast } = useToast();
+
+ async function handleDelete() {
+ setBusy(true);
+ const res = await fetch(`/api/plant-types/${id}`, { method: "DELETE" });
+ setBusy(false);
+ if (!res.ok) {
+ toast({ title: "Error", description: "Could not delete.", variant: "destructive" });
+ return;
+ }
+ toast({ title: "Deleted", description: `"${name}" removed from your plant types.` });
+ setOpen(false);
+ router.push("/garden/types");
+ router.refresh();
+ }
+
+ return (
+ <>
+ setOpen(true)}>
+
+ Delete
+
+
+
+
+ Delete “{name}”?
+
+ This removes the plant-type reference. Your individual plantings stay put —
+ they just won't be linked to a type anymore.
+
+
+
+ setOpen(false)}>Cancel
+
+ {busy ? "Deleting…" : "Delete"}
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/garden/plant-type-dialog.tsx b/src/components/garden/plant-type-dialog.tsx
new file mode 100644
index 0000000..f60dfe8
--- /dev/null
+++ b/src/components/garden/plant-type-dialog.tsx
@@ -0,0 +1,262 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { PlantCategory } from "@prisma/client";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
+} from "@/components/ui/dialog";
+import {
+ Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
+} from "@/components/ui/select";
+import { useToast } from "@/hooks/use-toast";
+import { Plus, Pencil } from "lucide-react";
+import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
+
+export type PlantTypeFields = {
+ id?: string;
+ commonName: string;
+ species: string | null;
+ category: PlantCategory;
+ careNotes: string | null;
+ sun: string | null;
+ water: string | null;
+ bloomStart: number | null;
+ bloomEnd: number | null;
+ harvestStart: number | null;
+ harvestEnd: number | null;
+ toxicToPets: boolean | null;
+ edibleParts: string | null;
+ spacing: string | null;
+ notes: string | null;
+};
+
+const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
+const NONE = "__none__";
+
+function blank(): PlantTypeFields {
+ return {
+ commonName: "", species: null, category: "OTHER", careNotes: null,
+ sun: null, water: null, bloomStart: null, bloomEnd: null,
+ harvestStart: null, harvestEnd: null, toxicToPets: null,
+ edibleParts: null, spacing: null, notes: null,
+ };
+}
+
+function MonthSelect({ value, onChange, placeholder }: {
+ value: number | null; onChange: (v: number | null) => void; placeholder: string;
+}) {
+ return (
+ onChange(v === NONE ? null : Number(v))}>
+
+
+ —
+ {MONTHS.map((m, i) => {m} )}
+
+
+ );
+}
+
+function PlantTypeDialog({
+ trigger, title, initial, onSaved,
+}: {
+ trigger: React.ReactNode;
+ title: string;
+ initial: PlantTypeFields;
+ onSaved: () => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [f, setF] = useState(initial);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+ const router = useRouter();
+ const { toast } = useToast();
+
+ function set(key: K, value: PlantTypeFields[K]) {
+ setF((p) => ({ ...p, [key]: value }));
+ }
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!f.commonName.trim()) { setError("Please enter the plant name."); return; }
+ setError("");
+ setBusy(true);
+
+ const payload: Record = {
+ commonName: f.commonName.trim(),
+ species: f.species ?? "",
+ category: f.category,
+ careNotes: f.careNotes ?? "",
+ sun: f.sun ?? "",
+ water: f.water ?? "",
+ edibleParts: f.edibleParts ?? "",
+ spacing: f.spacing ?? "",
+ notes: f.notes ?? "",
+ toxicToPets: f.toxicToPets,
+ };
+ // Month fields: only send when set (empty would fail the 1–12 check).
+ for (const k of ["bloomStart", "bloomEnd", "harvestStart", "harvestEnd"] as const) {
+ if (f[k] != null) payload[k] = f[k];
+ }
+
+ const res = await fetch(f.id ? `/api/plant-types/${f.id}` : "/api/plant-types", {
+ method: f.id ? "PATCH" : "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ setBusy(false);
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ toast({ title: "Error", description: err.error ?? "Could not save.", variant: "destructive" });
+ return;
+ }
+ toast({ title: f.id ? "Saved!" : "Plant type added!" });
+ setOpen(false);
+ onSaved();
+ router.refresh();
+ }
+
+ return (
+ <>
+ { setF(initial); setOpen(true); }}>{trigger}
+
+
+ {title}
+
+
+
+ >
+ );
+}
+
+export function AddPlantTypeButton() {
+ return (
+ {}}
+ trigger={ Add type }
+ />
+ );
+}
+
+export function EditPlantTypeButton({ plantType }: { plantType: PlantTypeFields }) {
+ return (
+ {}}
+ trigger={ Edit }
+ />
+ );
+}
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx
index 56c004c..62f9a1b 100644
--- a/src/components/layout/sidebar.tsx
+++ b/src/components/layout/sidebar.tsx
@@ -2,12 +2,13 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
-import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw } from "lucide-react";
+import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw, BookOpen } from "lucide-react";
import { cn } from "@/lib/utils";
const navItems = [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ label: "Garden", href: "/garden", icon: Leaf },
+ { label: "Plant types", href: "/garden/types", icon: BookOpen },
{ label: "Reminders", href: "/tasks", icon: Bell },
// Home maintenance and Equipment come in future releases
// { label: "Home", href: "/home", icon: Home },
@@ -34,7 +35,14 @@ export function Sidebar() {
{navItems.map(({ label, href, icon: Icon }) => {
- const active = path === href || (href !== "/dashboard" && path.startsWith(href));
+ // Active on exact match or a sub-path, unless a more specific nav item
+ // (e.g. /garden/types) already claims the current path.
+ const moreSpecific = navItems.some(
+ (o) => o.href.length > href.length && (path === o.href || path.startsWith(o.href + "/"))
+ );
+ const active =
+ path === href ||
+ (href !== "/dashboard" && path.startsWith(href + "/") && !moreSpecific);
return (
{
+ for (const entry of PLANT_DB) {
+ const id = libraryTypeId(entry.commonName, entry.species);
+ await db.plantType.upsert({
+ where: { id },
+ update: {},
+ create: {
+ id,
+ commonName: entry.commonName,
+ species: entry.species ?? null,
+ category: entry.category,
+ careNotes: entry.notes ?? null,
+ },
+ });
+ }
+
+ const types = await db.plantType.findMany({
+ select: { id: true, commonName: true, species: true },
+ });
+ const bySpecies = new Map();
+ const byCommon = new Map();
+ for (const t of types) {
+ if (t.species) bySpecies.set(t.species.toLowerCase().trim(), t.id);
+ byCommon.set(t.commonName.toLowerCase().trim(), t.id);
+ }
+
+ const unlinked = await db.plant.findMany({
+ where: { plantTypeId: null },
+ select: { id: true, commonName: true, species: true },
+ });
+ let linked = 0;
+ for (const p of unlinked) {
+ const typeId =
+ (p.species && bySpecies.get(p.species.toLowerCase().trim())) ||
+ byCommon.get(p.commonName.toLowerCase().trim());
+ if (typeId) {
+ await db.plant.update({ where: { id: p.id }, data: { plantTypeId: typeId } });
+ linked++;
+ }
+ }
+
+ return { types: PLANT_DB.length, linked };
+}
diff --git a/src/lib/version.ts b/src/lib/version.ts
index 52ea7ff..e989337 100644
--- a/src/lib/version.ts
+++ b/src/lib/version.ts
@@ -1,2 +1,2 @@
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
-export const APP_VERSION = "0.10.0";
+export const APP_VERSION = "0.11.0";