v0.11.0 — Plant types: the reusable "kind" record

A rich-but-all-optional reference record per kind of plant (care, sun, water,
bloom & harvest windows, toxic-to-pets, edible parts, spacing), distinct from a
specific planting or a group.

- PlantType model + optional Plant.plantTypeId; migration + idempotent backfill
  that seeds 247 kinds from the built-in plant library and links plantings by
  species/common name (wired into the deploy entrypoint)
- /api/plant-types CRUD; new "Plant types" section (browse by category, edit a
  kind, see every spot you've planted it)
- New plants auto-link to their type on create; plant detail shows "More about…"
- PlantType added to backup/restore; bump APP_VERSION 0.11.0 + changelog

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 23:46:07 -05:00
parent 9c0312676a
commit cdb21ed58a
20 changed files with 984 additions and 8 deletions

View File

@@ -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 "$@"

View File

@@ -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",

View File

@@ -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;

View File

@@ -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 112
bloomEnd Int? // month 112
harvestStart Int? // month 112
harvestEnd Int? // month 112
toxicToPets Boolean? // null = unknown
edibleParts String? // "Fruit, young leaves"
spacing String? // "1015 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

View File

@@ -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());

View File

@@ -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.");
}

View File

@@ -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
<span>{placeName}</span>
</div>
)}
{plant.plantType && (
<div className="flex items-center gap-2 text-muted-foreground">
<BookOpen className="h-4 w-4 shrink-0" />
<Link href={`/garden/types/${plant.plantType.id}`} className="hover:text-foreground underline-offset-2 hover:underline">
More about {plant.plantType.commonName}
</Link>
</div>
)}
{plant.plantedAt && (
<div className="flex items-center gap-2 text-muted-foreground">
<CalendarDays className="h-4 w-4 shrink-0" />

View File

@@ -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: <Sun className="h-4 w-4" />, label: "Sun", value: type.sun });
if (type.water) facts.push({ icon: <Droplets className="h-4 w-4" />, label: "Water", value: type.water });
if (bloom) facts.push({ icon: <Flower2 className="h-4 w-4" />, label: "Blooms", value: bloom });
if (harvest) facts.push({ icon: <Apple className="h-4 w-4" />, label: "Harvest", value: harvest });
if (type.edibleParts) facts.push({ icon: <Apple className="h-4 w-4" />, label: "Edible", value: type.edibleParts });
if (type.spacing) facts.push({ icon: <Ruler className="h-4 w-4" />, label: "Spacing", value: type.spacing });
return (
<div className="space-y-6 max-w-2xl">
<div className="flex items-center gap-3">
<Link href="/garden/types" className="text-muted-foreground hover:text-foreground transition-colors">
<ChevronLeft className="h-5 w-5" />
</Link>
<div className="min-w-0">
<h1 className="font-display text-2xl font-semibold">{type.commonName}</h1>
{type.species && <p className="text-sm text-muted-foreground italic">{type.species}</p>}
</div>
<Badge variant="secondary" className="ml-auto shrink-0">{CATEGORY_LABELS[type.category]}</Badge>
</div>
<div className="flex items-center gap-2">
<EditPlantTypeButton plantType={formFields} />
<DeletePlantTypeButton id={type.id} name={type.commonName} />
</div>
{type.toxicToPets && (
<div className="flex items-center gap-2 text-sm rounded-lg border border-[hsl(var(--ember)/.4)] bg-[hsl(var(--ember)/.06)] px-3 py-2 text-[hsl(var(--ember))]">
<AlertTriangle className="h-4 w-4 shrink-0" />
Toxic to pets keep away from animals.
</div>
)}
{type.toxicToPets === false && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="text-[hsl(var(--leaf))]"></span> Safe for pets.
</div>
)}
{facts.length > 0 && (
<Card>
<CardContent className="pt-4 grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
{facts.map((fct, i) => (
<div key={i} className="flex items-start gap-2">
<span className="text-muted-foreground mt-0.5 shrink-0">{fct.icon}</span>
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{fct.label}</p>
<p className="truncate">{fct.value}</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
{(type.careNotes || type.notes) && (
<Card>
<CardContent className="pt-4 space-y-3 text-sm">
{type.careNotes && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Care</p>
<p className="whitespace-pre-wrap">{type.careNotes}</p>
</div>
)}
{type.notes && (
<div className={type.careNotes ? "pt-2 border-t" : ""}>
<p className="whitespace-pre-wrap">{type.notes}</p>
</div>
)}
</CardContent>
</Card>
)}
<div>
<h2 className="font-display text-lg font-semibold mb-3">
Your plantings {type.plants.length > 0 && <span className="text-sm text-muted-foreground font-normal">· {type.plants.length}</span>}
</h2>
{type.plants.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm border rounded-lg">
None planted yet this is general info you can keep regardless.
</div>
) : (
<div className="divide-y border rounded-lg overflow-hidden">
{type.plants.map((p) => (
<Link key={p.id} href={`/garden/${p.id}`}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-accent transition-colors text-sm">
<Leaf className="h-4 w-4 shrink-0 text-[hsl(var(--leaf))]" />
<span className="font-medium">
{p.commonName}{p.variety && <span className="text-muted-foreground font-normal"> · {p.variety}</span>}
</span>
{p.location?.name && (
<span className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="h-3 w-3" />{p.location.name}
</span>
)}
</Link>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold">Plant types</h1>
<p className="text-sm text-muted-foreground mt-0.5">
General knowledge about each kind of plant care, sun, bloom &amp; harvest, toxicity.
{" "}{types.length} {types.length === 1 ? "kind" : "kinds"} in your library.
</p>
</div>
<AddPlantTypeButton />
</div>
{types.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="font-medium">No plant types yet</p>
<p className="text-sm mt-1">Add one, or they&apos;ll appear here as you add plants.</p>
</div>
) : (
<div className="space-y-6">
{byCategory.map((group) => (
<div key={group.category} className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{CATEGORY_LABELS[group.category]}
</p>
<div className="divide-y border rounded-lg overflow-hidden">
{group.types.map((t) => (
<Link key={t.id} href={`/garden/types/${t.id}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors">
<Leaf className="h-4 w-4 shrink-0 text-[hsl(var(--leaf))]" />
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{t.commonName}</p>
{t.species && <p className="text-xs text-muted-foreground italic truncate">{t.species}</p>}
</div>
{t.toxicToPets && (
<Badge variant="outline" className="gap-1 text-xs text-[hsl(var(--ember))] border-[hsl(var(--ember)/.4)]">
<AlertTriangle className="h-3 w-3" /> Toxic
</Badge>
)}
{t._count.plants > 0 && (
<Badge variant="secondary" className="text-xs shrink-0">
{t._count.plants} planted
</Badge>
)}
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</Link>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -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)),

View File

@@ -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 });

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -28,6 +28,22 @@ async function resolveZone(locationId?: string, zone?: string): Promise<string |
return zone?.trim() || null;
}
// Link a new planting to its "kind" record — by species first, else common name.
async function resolvePlantTypeId(species: string | null, commonName: string): Promise<string | null> {
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,

View File

@@ -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 (
<>
<Button variant="outline" size="sm" className="text-[hsl(var(--ember))]" onClick={() => setOpen(true)}>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Delete &ldquo;{name}&rdquo;?</DialogTitle>
<DialogDescription>
This removes the plant-type reference. Your individual plantings stay put
they just won&apos;t be linked to a type anymore.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleDelete} disabled={busy}
className="bg-[hsl(var(--ember))] hover:bg-[hsl(var(--ember)/.85)]">
{busy ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -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 (
<Select value={value ? String(value) : NONE} onValueChange={(v) => onChange(v === NONE ? null : Number(v))}>
<SelectTrigger><SelectValue placeholder={placeholder} /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}></SelectItem>
{MONTHS.map((m, i) => <SelectItem key={m} value={String(i + 1)}>{m}</SelectItem>)}
</SelectContent>
</Select>
);
}
function PlantTypeDialog({
trigger, title, initial, onSaved,
}: {
trigger: React.ReactNode;
title: string;
initial: PlantTypeFields;
onSaved: () => void;
}) {
const [open, setOpen] = useState(false);
const [f, setF] = useState<PlantTypeFields>(initial);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const router = useRouter();
const { toast } = useToast();
function set<K extends keyof PlantTypeFields>(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<string, unknown> = {
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 112 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 (
<>
<span onClick={() => { setF(initial); setOpen(true); }}>{trigger}</span>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1.5">
<Label>Plant name *</Label>
<Input value={f.commonName} onChange={(e) => set("commonName", e.target.value)}
placeholder="Lily of the valley" />
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<div className="space-y-1.5">
<Label>Scientific name</Label>
<Input className="italic" value={f.species ?? ""} onChange={(e) => set("species", e.target.value)}
placeholder="Convallaria majalis" />
</div>
<div className="space-y-1.5">
<Label>Category</Label>
<Select value={f.category} onValueChange={(v) => set("category", v as PlantCategory)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{CATEGORY_ORDER.map((c) => <SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Sun</Label>
<Select value={f.sun ?? NONE} onValueChange={(v) => set("sun", v === NONE ? null : v)}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}></SelectItem>
<SelectItem value="Full sun">Full sun</SelectItem>
<SelectItem value="Part shade">Part shade</SelectItem>
<SelectItem value="Shade">Shade</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Water</Label>
<Select value={f.water ?? NONE} onValueChange={(v) => set("water", v === NONE ? null : v)}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}></SelectItem>
<SelectItem value="Low">Low</SelectItem>
<SelectItem value="Moderate">Moderate</SelectItem>
<SelectItem value="High">High</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Bloom window</Label>
<div className="flex items-center gap-1.5">
<MonthSelect value={f.bloomStart} onChange={(v) => set("bloomStart", v)} placeholder="From" />
<span className="text-muted-foreground text-xs">to</span>
<MonthSelect value={f.bloomEnd} onChange={(v) => set("bloomEnd", v)} placeholder="To" />
</div>
</div>
<div className="space-y-1.5">
<Label>Harvest window</Label>
<div className="flex items-center gap-1.5">
<MonthSelect value={f.harvestStart} onChange={(v) => set("harvestStart", v)} placeholder="From" />
<span className="text-muted-foreground text-xs">to</span>
<MonthSelect value={f.harvestEnd} onChange={(v) => set("harvestEnd", v)} placeholder="To" />
</div>
</div>
<div className="space-y-1.5">
<Label>Toxic to pets?</Label>
<Select
value={f.toxicToPets == null ? NONE : f.toxicToPets ? "yes" : "no"}
onValueChange={(v) => set("toxicToPets", v === NONE ? null : v === "yes")}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Unknown</SelectItem>
<SelectItem value="yes">Yes toxic</SelectItem>
<SelectItem value="no">No safe</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Spacing</Label>
<Input value={f.spacing ?? ""} onChange={(e) => set("spacing", e.target.value)} placeholder="1015 ft" />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Edible parts</Label>
<Input value={f.edibleParts ?? ""} onChange={(e) => set("edibleParts", e.target.value)}
placeholder="Fruit, young leaves" />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Care notes</Label>
<Textarea rows={3} value={f.careNotes ?? ""} onChange={(e) => set("careNotes", e.target.value)}
placeholder="How to care for this kind of plant in general…" />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Other notes</Label>
<Textarea rows={2} value={f.notes ?? ""} onChange={(e) => set("notes", e.target.value)} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}
export function AddPlantTypeButton() {
return (
<PlantTypeDialog
title="Add a plant type"
initial={blank()}
onSaved={() => {}}
trigger={<Button size="sm"><Plus className="h-4 w-4 mr-1" />Add type</Button>}
/>
);
}
export function EditPlantTypeButton({ plantType }: { plantType: PlantTypeFields }) {
return (
<PlantTypeDialog
title="Edit plant type"
initial={plantType}
onSaved={() => {}}
trigger={<Button variant="outline" size="sm"><Pencil className="h-4 w-4 mr-1" />Edit</Button>}
/>
);
}

View File

@@ -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() {
<nav className="flex-1 px-2 py-3 space-y-0.5">
{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 (
<Link
key={href}

View File

@@ -8,6 +8,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.11.0",
date: "2026-06-15",
changes: [
"New \"Plant types\" library — a place to keep general info about each kind of plant (lily of the valley, Honeycrisp apple…): care notes, sun and water needs, bloom and harvest months, whether it's toxic to pets, and more. Every field is optional. Your plants link to their type automatically, so opening a plant now shows \"More about…\" and a type page lists every spot you've planted it.",
],
},
{
version: "0.10.0",
date: "2026-06-15",

View File

@@ -0,0 +1,63 @@
import type { PrismaClient } from "@prisma/client";
import { PLANT_DB } from "./plant-db";
// Stable, deterministic id for a library-seeded PlantType so upserts are
// idempotent across seed / deploy-backfill / re-runs.
function slug(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
}
export function libraryTypeId(commonName: string, species?: string | null): string {
return `pt-${slug(commonName)}${species ? `-${slug(species)}` : ""}`;
}
export type PlantTypeSyncResult = { types: number; linked: number };
/**
* Idempotently upsert a PlantType for every entry in the built-in plant library
* (src/lib/garden/plant-db.ts), then link any Plant lacking a plantTypeId to its
* kind — by species first, else common name. `update: {}` so re-runs never
* clobber a user's edits to a type. Safe to run repeatedly.
*/
export async function syncPlantTypesFromLibrary(db: PrismaClient): Promise<PlantTypeSyncResult> {
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<string, string>();
const byCommon = new Map<string, string>();
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 };
}

View File

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