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:
@@ -14,4 +14,7 @@ echo "Running Prisma migrations..."
|
|||||||
echo "Running Location-spine backfill (idempotent)..."
|
echo "Running Location-spine backfill (idempotent)..."
|
||||||
./node_modules/.bin/tsx prisma/scripts/backfill-locations.ts || echo " Backfill skipped/failed (non-fatal); check logs."
|
./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 "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"db:migrate:deploy": "prisma migrate deploy",
|
"db:migrate:deploy": "prisma migrate deploy",
|
||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"db:backfill-locations": "tsx prisma/scripts/backfill-locations.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:studio": "prisma studio",
|
||||||
"db:reset": "prisma migrate reset --force",
|
"db:reset": "prisma migrate reset --force",
|
||||||
"db:backup": "sh scripts/db-backup.sh",
|
"db:backup": "sh scripts/db-backup.sh",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -126,6 +126,8 @@ model Plant {
|
|||||||
zone String? // legacy freeform location — kept during the Location migration (dropped in Release B)
|
zone String? // legacy freeform location — kept during the Location migration (dropped in Release B)
|
||||||
locationId String? // shared Location spine
|
locationId String? // shared Location spine
|
||||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
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
|
plantedAt DateTime? // when it went in the ground
|
||||||
source String? // where it came from ("Jung's Nursery", "from seed", "division from Mom's")
|
source String? // where it came from ("Jung's Nursery", "from seed", "division from Mom's")
|
||||||
notes String? // general notes about this plant
|
notes String? // general notes about this plant
|
||||||
@@ -141,10 +143,42 @@ model Plant {
|
|||||||
@@index([category])
|
@@index([category])
|
||||||
@@index([zone])
|
@@index([zone])
|
||||||
@@index([locationId])
|
@@index([locationId])
|
||||||
|
@@index([plantTypeId])
|
||||||
@@index([active])
|
@@index([active])
|
||||||
@@index([groupId])
|
@@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 {
|
enum PlantLogType {
|
||||||
OBSERVATION // general note — "looking healthy", "saw first flowers"
|
OBSERVATION // general note — "looking healthy", "saw first flowers"
|
||||||
CARE // watering, mulching, pruning, fertilizing, chop-and-drop
|
CARE // watering, mulching, pruning, fertilizing, chop-and-drop
|
||||||
|
|||||||
23
prisma/scripts/backfill-plant-types.ts
Normal file
23
prisma/scripts/backfill-plant-types.ts
Normal 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());
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PrismaClient, PlantCategory } from "@prisma/client";
|
import { PrismaClient, PlantCategory } from "@prisma/client";
|
||||||
import { hashSync } from "bcryptjs";
|
import { hashSync } from "bcryptjs";
|
||||||
|
import { syncPlantTypesFromLibrary } from "../src/lib/garden/plant-types";
|
||||||
|
|
||||||
const db = new PrismaClient();
|
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.");
|
console.log("Demo seed complete.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { db } from "@/lib/db";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { formatDate } from "@/lib/utils";
|
||||||
import { CATEGORY_LABELS, LOG_TYPE_LABELS, LOG_TYPE_COLORS } from "@/lib/garden/categories";
|
import { CATEGORY_LABELS, LOG_TYPE_LABELS, LOG_TYPE_COLORS } from "@/lib/garden/categories";
|
||||||
import { AddLogButton } from "@/components/garden/add-log-button";
|
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([
|
const [plant, locations, groups] = await Promise.all([
|
||||||
db.plant.findUnique({
|
db.plant.findUnique({
|
||||||
where: { id: params.id },
|
where: { id: params.id },
|
||||||
include: { logs: { orderBy: { date: "desc" } }, 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.location.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||||
db.plantGroup.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
db.plantGroup.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||||
@@ -66,6 +70,14 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
|||||||
<span>{placeName}</span>
|
<span>{placeName}</span>
|
||||||
</div>
|
</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 && (
|
{plant.plantedAt && (
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
<CalendarDays className="h-4 w-4 shrink-0" />
|
<CalendarDays className="h-4 w-4 shrink-0" />
|
||||||
|
|||||||
163
src/app/(dashboard)/garden/types/[id]/page.tsx
Normal file
163
src/app/(dashboard)/garden/types/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/app/(dashboard)/garden/types/page.tsx
Normal file
77
src/app/(dashboard)/garden/types/page.tsx
Normal 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 & 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,9 +10,10 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
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([
|
await Promise.all([
|
||||||
db.location.findMany(),
|
db.location.findMany(),
|
||||||
|
db.plantType.findMany(),
|
||||||
db.user.findMany(),
|
db.user.findMany(),
|
||||||
db.plantGroup.findMany(),
|
db.plantGroup.findMany(),
|
||||||
db.plant.findMany(),
|
db.plant.findMany(),
|
||||||
@@ -25,12 +26,13 @@ export async function GET() {
|
|||||||
const manifest = {
|
const manifest = {
|
||||||
version: 1,
|
version: 1,
|
||||||
exportedAt: new Date().toISOString(),
|
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({
|
const zip = zipSync({
|
||||||
"manifest.json": strToU8(JSON.stringify(manifest, null, 2)),
|
"manifest.json": strToU8(JSON.stringify(manifest, null, 2)),
|
||||||
"locations.json": strToU8(JSON.stringify(locations, null, 2)),
|
"locations.json": strToU8(JSON.stringify(locations, null, 2)),
|
||||||
|
"plantTypes.json": strToU8(JSON.stringify(plantTypes, null, 2)),
|
||||||
"users.json": strToU8(JSON.stringify(users, null, 2)),
|
"users.json": strToU8(JSON.stringify(users, null, 2)),
|
||||||
"plantGroups.json": strToU8(JSON.stringify(plantGroups, null, 2)),
|
"plantGroups.json": strToU8(JSON.stringify(plantGroups, null, 2)),
|
||||||
"plants.json": strToU8(JSON.stringify(plants, null, 2)),
|
"plants.json": strToU8(JSON.stringify(plants, null, 2)),
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ error: "Unsupported backup version" }, { status: 400 });
|
return NextResponse.json({ error: "Unsupported backup version" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Older backups (pre-v0.10) won't have locations.json — tolerate its absence.
|
// Older backups won't have newer tables — tolerate their absence.
|
||||||
const locations = files["locations.json"] ? parse("locations.json") : [];
|
const locations = files["locations.json"] ? parse("locations.json") : [];
|
||||||
|
const plantTypes = files["plantTypes.json"] ? parse("plantTypes.json") : [];
|
||||||
const users = parse("users.json");
|
const users = parse("users.json");
|
||||||
const plantGroups = parse("plantGroups.json");
|
const plantGroups = parse("plantGroups.json");
|
||||||
const plants = parse("plants.json");
|
const plants = parse("plants.json");
|
||||||
@@ -44,6 +45,7 @@ export async function POST(req: Request) {
|
|||||||
await tx.task.deleteMany();
|
await tx.task.deleteMany();
|
||||||
await tx.plant.deleteMany();
|
await tx.plant.deleteMany();
|
||||||
await tx.plantGroup.deleteMany();
|
await tx.plantGroup.deleteMany();
|
||||||
|
await tx.plantType.deleteMany();
|
||||||
await tx.location.deleteMany();
|
await tx.location.deleteMany();
|
||||||
await tx.auditEvent.deleteMany();
|
await tx.auditEvent.deleteMany();
|
||||||
await tx.user.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,
|
// Location self-references parentId; a single createMany is one statement,
|
||||||
// so the FK is validated only after every row exists — order-independent.
|
// so the FK is validated only after every row exists — order-independent.
|
||||||
if (locations.length) await tx.location.createMany({ data: locations });
|
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 (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups });
|
||||||
if (plants.length) await tx.plant.createMany({ data: plants });
|
if (plants.length) await tx.plant.createMany({ data: plants });
|
||||||
if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs });
|
if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs });
|
||||||
|
|||||||
104
src/app/api/plant-types/[id]/route.ts
Normal file
104
src/app/api/plant-types/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/app/api/plant-types/route.ts
Normal file
91
src/app/api/plant-types/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,22 @@ async function resolveZone(locationId?: string, zone?: string): Promise<string |
|
|||||||
return zone?.trim() || null;
|
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) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await requireAuth();
|
const session = await requireAuth();
|
||||||
@@ -38,6 +54,7 @@ export async function POST(req: Request) {
|
|||||||
const commonName = data.commonName.trim();
|
const commonName = data.commonName.trim();
|
||||||
const imageUrl = await fetchPlantImageUrl(species, commonName);
|
const imageUrl = await fetchPlantImageUrl(species, commonName);
|
||||||
const zone = await resolveZone(data.locationId, data.zone);
|
const zone = await resolveZone(data.locationId, data.zone);
|
||||||
|
const plantTypeId = await resolvePlantTypeId(species, commonName);
|
||||||
|
|
||||||
const plant = await db.plant.create({
|
const plant = await db.plant.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -47,6 +64,7 @@ export async function POST(req: Request) {
|
|||||||
category: data.category as PlantCategory,
|
category: data.category as PlantCategory,
|
||||||
zone, // dual-write: kept in sync with location.name until Release B
|
zone, // dual-write: kept in sync with location.name until Release B
|
||||||
locationId: data.locationId || null,
|
locationId: data.locationId || null,
|
||||||
|
plantTypeId,
|
||||||
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
|
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
|
||||||
source: data.source?.trim() || null,
|
source: data.source?.trim() || null,
|
||||||
notes: data.notes?.trim() || null,
|
notes: data.notes?.trim() || null,
|
||||||
|
|||||||
58
src/components/garden/delete-plant-type-button.tsx
Normal file
58
src/components/garden/delete-plant-type-button.tsx
Normal 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 “{name}”?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This removes the plant-type reference. Your individual plantings stay put —
|
||||||
|
they just won'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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
262
src/components/garden/plant-type-dialog.tsx
Normal file
262
src/components/garden/plant-type-dialog.tsx
Normal 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 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 (
|
||||||
|
<>
|
||||||
|
<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="10–15 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>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
||||||
{ label: "Garden", href: "/garden", icon: Leaf },
|
{ label: "Garden", href: "/garden", icon: Leaf },
|
||||||
|
{ label: "Plant types", href: "/garden/types", icon: BookOpen },
|
||||||
{ label: "Reminders", href: "/tasks", icon: Bell },
|
{ label: "Reminders", href: "/tasks", icon: Bell },
|
||||||
// Home maintenance and Equipment come in future releases
|
// Home maintenance and Equipment come in future releases
|
||||||
// { label: "Home", href: "/home", icon: Home },
|
// { 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">
|
<nav className="flex-1 px-2 py-3 space-y-0.5">
|
||||||
{navItems.map(({ label, href, icon: Icon }) => {
|
{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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.10.0",
|
||||||
date: "2026-06-15",
|
date: "2026-06-15",
|
||||||
|
|||||||
63
src/lib/garden/plant-types.ts
Normal file
63
src/lib/garden/plant-types.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
||||||
export const APP_VERSION = "0.10.0";
|
export const APP_VERSION = "0.11.0";
|
||||||
|
|||||||
Reference in New Issue
Block a user