diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..dd887c4
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,8 @@
+.git
+.gitignore
+node_modules
+.next
+.env
+.env.*
+npm-debug.log*
+README.md
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..224b48a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,31 @@
+FROM node:20-alpine AS deps
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci
+
+FROM node:20-alpine AS builder
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+RUN npx prisma generate
+RUN npm run build
+
+FROM node:20-alpine AS runner
+WORKDIR /app
+ENV NODE_ENV=production
+
+RUN addgroup -S moonbase && adduser -S moonbase -G moonbase
+
+COPY --from=builder /app/public ./public
+COPY --from=builder /app/.next/standalone ./
+COPY --from=builder /app/.next/static ./.next/static
+COPY --from=builder /app/prisma ./prisma
+COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
+COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
+COPY docker-entrypoint.sh ./
+RUN chmod +x docker-entrypoint.sh
+
+USER moonbase
+EXPOSE 3000
+
+ENTRYPOINT ["./docker-entrypoint.sh"]
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..2fa6afd
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,30 @@
+services:
+ postgres:
+ image: postgres:16-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: moonbase
+ POSTGRES_USER: moonbase
+ POSTGRES_PASSWORD: moonbase
+ volumes:
+ - /mnt/user/appdata/moonbase/postgres:/var/lib/postgresql/data
+ networks:
+ - internal
+
+ app:
+ image: moonbase:latest
+ restart: unless-stopped
+ depends_on:
+ - postgres
+ ports:
+ - "8053:3000"
+ environment:
+ DATABASE_URL: postgresql://moonbase:moonbase@postgres:5432/moonbase
+ NEXTAUTH_SECRET: 81Uds/ZnQNHZWMsHIIuqNVuBSzPqcVxdPyw1AewbjZA=
+ NEXTAUTH_URL: http://192.168.0.5:8053
+ networks:
+ - internal
+
+networks:
+ internal:
+ driver: bridge
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
new file mode 100644
index 0000000..7f8d344
--- /dev/null
+++ b/docker-entrypoint.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+set -e
+
+echo "Running Prisma migrations..."
+npx prisma migrate deploy
+
+echo "Starting Moon Base..."
+exec node server.js
diff --git a/next.config.mjs b/next.config.mjs
index 68a8854..73201d7 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
+ output: "standalone",
images: {
remotePatterns: [{ protocol: "https", hostname: "**" }],
},
diff --git a/prisma/migrations/20260615000002_v0_3_tasks/migration.sql b/prisma/migrations/20260615000002_v0_3_tasks/migration.sql
new file mode 100644
index 0000000..05cc43e
--- /dev/null
+++ b/prisma/migrations/20260615000002_v0_3_tasks/migration.sql
@@ -0,0 +1,38 @@
+-- Moon Base v0.3 — recurring tasks / reminders
+
+CREATE TYPE "TaskCategory" AS ENUM ('GARDEN', 'HOME', 'EQUIPMENT', 'OTHER');
+CREATE TYPE "RecurrenceType" AS ENUM ('ONCE', 'CUSTOM', 'MONTHLY', 'YEARLY');
+
+CREATE TABLE "Task" (
+ "id" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "notes" TEXT,
+ "category" "TaskCategory" NOT NULL DEFAULT 'GARDEN',
+ "plantId" TEXT,
+ -- Recurrence
+ "recurrence" "RecurrenceType" NOT NULL DEFAULT 'YEARLY',
+ "intervalDays" INTEGER, -- for CUSTOM: every N days
+ "month" INTEGER, -- 1-12: for YEARLY (which month) or MONTHLY (ignored)
+ -- Scheduling
+ "nextDue" TIMESTAMP(3) NOT NULL,
+ "lastDone" TIMESTAMP(3),
+ "active" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ CONSTRAINT "Task_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "Task_plantId_fkey" FOREIGN KEY ("plantId") REFERENCES "Plant"("id") ON DELETE SET NULL ON UPDATE CASCADE
+);
+CREATE INDEX "Task_nextDue_idx" ON "Task"("nextDue");
+CREATE INDEX "Task_plantId_idx" ON "Task"("plantId");
+CREATE INDEX "Task_active_idx" ON "Task"("active");
+
+CREATE TABLE "TaskCompletion" (
+ "id" TEXT NOT NULL,
+ "taskId" TEXT NOT NULL,
+ "doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "notes" TEXT,
+ "actorId" TEXT,
+ CONSTRAINT "TaskCompletion_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "TaskCompletion_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE RESTRICT ON UPDATE CASCADE
+);
+CREATE INDEX "TaskCompletion_taskId_idx" ON "TaskCompletion"("taskId");
diff --git a/prisma/migrations/20260615000003_v0_4_plant_groups/migration.sql b/prisma/migrations/20260615000003_v0_4_plant_groups/migration.sql
new file mode 100644
index 0000000..5b91f6f
--- /dev/null
+++ b/prisma/migrations/20260615000003_v0_4_plant_groups/migration.sql
@@ -0,0 +1,23 @@
+-- v0.4.0 — Plant groups (irises, hostas, etc.)
+
+CREATE TABLE "PlantGroup" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "notes" TEXT,
+ "active" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "PlantGroup_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "PlantGroup_name_key" ON "PlantGroup"("name");
+CREATE INDEX "PlantGroup_active_idx" ON "PlantGroup"("active");
+
+ALTER TABLE "Plant" ADD COLUMN "groupId" TEXT;
+
+ALTER TABLE "Plant"
+ ADD CONSTRAINT "Plant_groupId_fkey"
+ FOREIGN KEY ("groupId") REFERENCES "PlantGroup"("id")
+ ON DELETE SET NULL ON UPDATE CASCADE;
+
+CREATE INDEX "Plant_groupId_idx" ON "Plant"("groupId");
diff --git a/prisma/migrations/20260615000004_v0_5_plant_image/migration.sql b/prisma/migrations/20260615000004_v0_5_plant_image/migration.sql
new file mode 100644
index 0000000..648e9a1
--- /dev/null
+++ b/prisma/migrations/20260615000004_v0_5_plant_image/migration.sql
@@ -0,0 +1,2 @@
+-- v0.5.0 — Plant image URL (fetched from Wikipedia on add/edit)
+ALTER TABLE "Plant" ADD COLUMN "imageUrl" TEXT;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 0772784..4b7dc67 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -71,6 +71,18 @@ enum PlantCategory {
OTHER
}
+model PlantGroup {
+ id String @id @default(cuid())
+ name String @unique
+ notes String?
+ active Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ plants Plant[]
+
+ @@index([active])
+}
+
model Plant {
id String @id @default(cuid())
commonName String
@@ -81,14 +93,19 @@ model Plant {
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
+ groupId String? // optional: belongs to a PlantGroup (e.g. "Irises")
+ group PlantGroup? @relation(fields: [groupId], references: [id])
+ imageUrl String? // thumbnail fetched from Wikipedia on add/edit
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
logs PlantLog[]
+ tasks Task[]
@@index([category])
@@index([zone])
@@index([active])
+ @@index([groupId])
}
enum PlantLogType {
@@ -100,6 +117,57 @@ enum PlantLogType {
DIED // plant died or was removed
}
+// ---------------------------------------------------------------------------
+// Tasks / reminders
+// ---------------------------------------------------------------------------
+
+enum TaskCategory {
+ GARDEN
+ HOME
+ EQUIPMENT
+ OTHER
+}
+
+enum RecurrenceType {
+ ONCE // one-time task, no repeat
+ CUSTOM // every N days (intervalDays)
+ MONTHLY // same day every month (uses nextDue day-of-month)
+ YEARLY // same month every year (month field)
+}
+
+model Task {
+ id String @id @default(cuid())
+ title String
+ notes String?
+ category TaskCategory @default(GARDEN)
+ plantId String?
+ plant Plant? @relation(fields: [plantId], references: [id])
+ recurrence RecurrenceType @default(YEARLY)
+ intervalDays Int? // for CUSTOM: every N days
+ month Int? // 1–12: for YEARLY, which month
+ nextDue DateTime
+ lastDone DateTime?
+ active Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ completions TaskCompletion[]
+
+ @@index([nextDue])
+ @@index([plantId])
+ @@index([active])
+}
+
+model TaskCompletion {
+ id String @id @default(cuid())
+ taskId String
+ task Task @relation(fields: [taskId], references: [id])
+ doneAt DateTime @default(now())
+ notes String?
+ actorId String?
+
+ @@index([taskId])
+}
+
model PlantLog {
id String @id @default(cuid())
plantId String
diff --git a/src/app/(dashboard)/garden/[id]/page.tsx b/src/app/(dashboard)/garden/[id]/page.tsx
index 1065bc1..676c20a 100644
--- a/src/app/(dashboard)/garden/[id]/page.tsx
+++ b/src/app/(dashboard)/garden/[id]/page.tsx
@@ -16,15 +16,19 @@ export async function generateMetadata({ params }: { params: { id: string } }) {
}
export default async function PlantDetailPage({ params }: { params: { id: string } }) {
- const plant = await db.plant.findUnique({
- where: { id: params.id },
- include: {
- logs: { orderBy: { date: "desc" } },
- },
- });
+ const [plant, allPlants, groups] = await Promise.all([
+ db.plant.findUnique({
+ where: { id: params.id },
+ include: { logs: { orderBy: { date: "desc" } } },
+ }),
+ db.plant.findMany({ where: { active: true }, select: { zone: true } }),
+ db.plantGroup.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
+ ]);
if (!plant || !plant.active) notFound();
+ const zones = Array.from(new Set(allPlants.map((p) => p.zone).filter(Boolean) as string[])).sort();
+
return (
@@ -44,10 +48,16 @@ export default async function PlantDetailPage({ params }: { params: { id: string
-
+
+ {plant.imageUrl && (
+
+

+
+ )}
+
{plant.zone && (
diff --git a/src/app/(dashboard)/garden/page.tsx b/src/app/(dashboard)/garden/page.tsx
index 83a5ccf..b4cebee 100644
--- a/src/app/(dashboard)/garden/page.tsx
+++ b/src/app/(dashboard)/garden/page.tsx
@@ -1,23 +1,28 @@
import { db } from "@/lib/db";
-import Link from "next/link";
-import { Card, CardContent } from "@/components/ui/card";
-import { Badge } from "@/components/ui/badge";
-import { Leaf, Plus, MapPin, CalendarDays } from "lucide-react";
-import { formatDate } from "@/lib/utils";
import { AddPlantButton } from "@/components/garden/add-plant-button";
-import { CATEGORY_LABELS } from "@/lib/garden/categories";
+import { GardenGrid } from "@/components/garden/garden-grid";
export const metadata = { title: "Garden" };
export default async function GardenPage() {
- const plants = await db.plant.findMany({
- where: { active: true },
- orderBy: [{ category: "asc" }, { commonName: "asc" }],
- include: {
- _count: { select: { logs: true } },
- logs: { orderBy: { date: "desc" }, take: 1, select: { date: true, type: true } },
- },
- });
+ const [plants, groups] = await Promise.all([
+ db.plant.findMany({
+ where: { active: true },
+ orderBy: [{ commonName: "asc" }],
+ include: {
+ _count: { select: { logs: true } },
+ logs: { orderBy: { date: "desc" }, take: 1, select: { date: true, type: true } },
+ group: { select: { id: true, name: true } },
+ },
+ }),
+ db.plantGroup.findMany({
+ where: { active: true },
+ orderBy: { name: "asc" },
+ include: { _count: { select: { plants: true } } },
+ }),
+ ]);
+
+ const zones = Array.from(new Set(plants.map((p) => p.zone).filter(Boolean) as string[])).sort();
return (
@@ -28,70 +33,10 @@ export default async function GardenPage() {
{plants.length} {plants.length === 1 ? "plant" : "plants"} on your acre
-
+
- {plants.length === 0 ? (
-
-
-
No plants yet
-
Add your first plant to start building your food forest registry.
-
- ) : (
-
- {plants.map((plant) => (
-
-
-
-
-
-
- {plant.commonName}
- {plant.variety && (
- · {plant.variety}
- )}
-
- {plant.species && (
-
{plant.species}
- )}
-
-
- {CATEGORY_LABELS[plant.category]}
-
-
-
-
- {plant.zone && (
-
-
- {plant.zone}
-
- )}
- {plant.plantedAt && (
-
-
- Planted {formatDate(plant.plantedAt)}
-
- )}
- {plant.logs[0] && (
-
-
-
- Last {plant.logs[0].type.toLowerCase()} {formatDate(plant.logs[0].date)}
-
-
- )}
-
-
-
- {plant._count.logs} log {plant._count.logs === 1 ? "entry" : "entries"}
-
-
-
-
- ))}
-
- )}
+
);
}
diff --git a/src/app/(dashboard)/tasks/page.tsx b/src/app/(dashboard)/tasks/page.tsx
new file mode 100644
index 0000000..36893f6
--- /dev/null
+++ b/src/app/(dashboard)/tasks/page.tsx
@@ -0,0 +1,158 @@
+import { db } from "@/lib/db";
+import { isOverdue, isDueSoon, describeRecurrence } from "@/lib/tasks/schedule";
+import { formatDate } from "@/lib/utils";
+import { AddTaskButton } from "@/components/tasks/add-task-button";
+import { CompleteTaskButton } from "@/components/tasks/complete-task-button";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent } from "@/components/ui/card";
+import { CalendarDays, Leaf, Home, Wrench, CheckCircle2, AlertCircle, Clock } from "lucide-react";
+import Link from "next/link";
+
+export const metadata = { title: "Reminders" };
+
+const CATEGORY_ICONS = {
+ GARDEN: Leaf,
+ HOME: Home,
+ EQUIPMENT: Wrench,
+ OTHER: Clock,
+};
+
+const CATEGORY_LABELS = {
+ GARDEN: "Garden",
+ HOME: "Home",
+ EQUIPMENT: "Equipment",
+ OTHER: "Other",
+};
+
+export default async function TasksPage() {
+ const tasks = await db.task.findMany({
+ where: { active: true },
+ orderBy: { nextDue: "asc" },
+ include: { plant: { select: { id: true, commonName: true, variety: true } } },
+ });
+
+ const overdue = tasks.filter((t) => isOverdue(t.nextDue));
+ const upcoming = tasks.filter((t) => !isOverdue(t.nextDue) && isDueSoon(t.nextDue, 60));
+ const later = tasks.filter((t) => !isOverdue(t.nextDue) && !isDueSoon(t.nextDue, 60));
+
+ return (
+
+
+
+
Reminders
+
+ {overdue.length > 0
+ ? `${overdue.length} overdue · ${upcoming.length} coming up`
+ : `${upcoming.length} coming up in the next 60 days`}
+
+
+
+
+
+ {tasks.length === 0 && (
+
+
+
No reminders yet
+
Add your first reminder to start tracking what needs doing.
+
+ )}
+
+ {overdue.length > 0 && (
+
+ {overdue.map((task) => (
+
+ ))}
+
+ )}
+
+ {upcoming.length > 0 && (
+
+ {upcoming.map((task) => (
+
+ ))}
+
+ )}
+
+ {later.length > 0 && (
+
+ {later.map((task) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function Section({ title, icon: Icon, iconClass, children }: {
+ title: string;
+ icon: React.ElementType;
+ iconClass: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
{title}
+
+ {children}
+
+ );
+}
+
+function TaskCard({ task, overdue = false }: {
+ task: Awaited>[0] & {
+ plant: { id: string; commonName: string; variety: string | null } | null;
+ };
+ overdue?: boolean;
+}) {
+ const Icon = CATEGORY_ICONS[task.category];
+ const daysUntil = Math.ceil((new Date(task.nextDue).getTime() - Date.now()) / 86400000);
+
+ return (
+
+
+
+
+
+
+
{task.title}
+
+ {CATEGORY_LABELS[task.category]}
+
+
+
+ {task.plant && (
+
+ {task.plant.commonName}{task.plant.variety ? ` · ${task.plant.variety}` : ""}
+
+ )}
+
+ {task.notes && (
+
{task.notes}
+ )}
+
+
+ {describeRecurrence(task.recurrence, task.intervalDays, task.month)}
+ {task.lastDone && ` · Last done ${formatDate(task.lastDone)}`}
+
+
+
+
+
+
+ {overdue
+ ? `${Math.abs(daysUntil)}d overdue`
+ : daysUntil === 0 ? "Today"
+ : daysUntil === 1 ? "Tomorrow"
+ : `In ${daysUntil} days`}
+
+
{formatDate(task.nextDue)}
+
+
+
+
+
+ );
+}
diff --git a/src/app/api/plant-groups/[id]/log/route.ts b/src/app/api/plant-groups/[id]/log/route.ts
new file mode 100644
index 0000000..fbc159d
--- /dev/null
+++ b/src/app/api/plant-groups/[id]/log/route.ts
@@ -0,0 +1,57 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { db } from "@/lib/db";
+import { requireAuth } from "@/lib/auth";
+
+const schema = z.object({
+ type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
+ notes: z.string().optional(),
+ quantity: z.string().optional(),
+ date: z.string().optional(),
+});
+
+export async function POST(req: Request, { params }: { params: { id: string } }) {
+ try {
+ const session = await requireAuth();
+ const group = await db.plantGroup.findUnique({
+ where: { id: params.id },
+ include: { plants: { where: { active: true }, select: { id: true } } },
+ });
+ if (!group || !group.active) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const data = schema.parse(await req.json());
+ const date = data.date ? new Date(data.date) : new Date();
+
+ // Write a log entry for every active plant in the group.
+ const logs = await db.$transaction(
+ group.plants.map((p) =>
+ db.plantLog.create({
+ data: {
+ plantId: p.id,
+ type: data.type,
+ date,
+ notes: data.notes?.trim() || null,
+ quantity: data.quantity?.trim() || null,
+ actorId: session.user.id,
+ },
+ })
+ )
+ );
+
+ await db.auditEvent.create({
+ data: {
+ action: "plant_group.log",
+ entityId: group.id,
+ actorId: session.user.id,
+ payload: { groupName: group.name, type: data.type, plantCount: logs.length },
+ },
+ });
+
+ return NextResponse.json({ count: logs.length }, { status: 201 });
+ } catch (err) {
+ if (err instanceof z.ZodError) return NextResponse.json({ error: err.issues[0]?.message }, { status: 400 });
+ if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ console.error(err);
+ return NextResponse.json({ error: "Server error" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/plant-groups/route.ts b/src/app/api/plant-groups/route.ts
new file mode 100644
index 0000000..9e67caf
--- /dev/null
+++ b/src/app/api/plant-groups/route.ts
@@ -0,0 +1,37 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { db } from "@/lib/db";
+import { requireAuth } from "@/lib/auth";
+
+const schema = z.object({
+ name: z.string().min(1),
+ notes: z.string().optional(),
+});
+
+export async function GET() {
+ const groups = await db.plantGroup.findMany({
+ where: { active: true },
+ orderBy: { name: "asc" },
+ include: { _count: { select: { plants: true } } },
+ });
+ return NextResponse.json(groups);
+}
+
+export async function POST(req: Request) {
+ try {
+ const session = await requireAuth();
+ const data = schema.parse(await req.json());
+ const group = await db.plantGroup.create({
+ data: { name: data.name.trim(), notes: data.notes?.trim() || null },
+ });
+ await db.auditEvent.create({
+ data: { action: "plant_group.create", entityId: group.id, actorId: session.user.id, payload: { name: group.name } },
+ });
+ return NextResponse.json(group, { status: 201 });
+ } catch (err) {
+ if (err instanceof z.ZodError) return NextResponse.json({ error: err.issues[0]?.message }, { status: 400 });
+ if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ console.error(err);
+ return NextResponse.json({ error: "Server error" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/plants/[id]/route.ts b/src/app/api/plants/[id]/route.ts
index 3bd0d26..48f5df8 100644
--- a/src/app/api/plants/[id]/route.ts
+++ b/src/app/api/plants/[id]/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth, isAdmin } from "@/lib/auth";
+import { fetchPlantImageUrl } from "@/lib/garden/plant-image";
const updateSchema = z.object({
commonName: z.string().min(1),
species: z.string().optional(),
@@ -11,6 +12,7 @@ const updateSchema = z.object({
plantedAt: z.string().optional(),
source: z.string().optional(),
notes: z.string().optional(),
+ groupId: z.string().nullable().optional(),
});
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
@@ -22,17 +24,29 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
const body = await req.json();
const data = updateSchema.parse(body);
+ const species = data.species?.trim() || null;
+ const commonName = data.commonName.trim();
+
+ // Re-fetch image if species or name changed.
+ const speciesChanged = species !== plant.species;
+ const nameChanged = commonName !== plant.commonName;
+ const imageUrl = (speciesChanged || nameChanged || !plant.imageUrl)
+ ? await fetchPlantImageUrl(species, commonName)
+ : undefined;
+
const updated = await db.plant.update({
where: { id: params.id },
data: {
- commonName: data.commonName.trim(),
- species: data.species?.trim() || null,
+ commonName,
+ species,
variety: data.variety?.trim() || null,
category: data.category,
zone: data.zone?.trim() || null,
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
source: data.source?.trim() || null,
notes: data.notes?.trim() || null,
+ groupId: data.groupId ?? null,
+ ...(imageUrl !== undefined ? { imageUrl } : {}),
},
});
diff --git a/src/app/api/plants/route.ts b/src/app/api/plants/route.ts
index 2df3145..48e602f 100644
--- a/src/app/api/plants/route.ts
+++ b/src/app/api/plants/route.ts
@@ -2,15 +2,17 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
+import { fetchPlantImageUrl } from "@/lib/garden/plant-image";
const createSchema = z.object({
commonName: z.string().min(1),
species: z.string().optional(),
variety: z.string().optional(),
category: z.string().min(1),
zone: z.string().optional(),
- plantedAt: z.string().optional(), // ISO date string
+ plantedAt: z.string().optional(),
source: z.string().optional(),
notes: z.string().optional(),
+ groupId: z.string().optional(),
});
export async function POST(req: Request) {
@@ -19,16 +21,22 @@ export async function POST(req: Request) {
const body = await req.json();
const data = createSchema.parse(body);
+ const species = data.species?.trim() || null;
+ const commonName = data.commonName.trim();
+ const imageUrl = await fetchPlantImageUrl(species, commonName);
+
const plant = await db.plant.create({
data: {
- commonName: data.commonName.trim(),
- species: data.species?.trim() || null,
+ commonName,
+ species,
variety: data.variety?.trim() || null,
category: data.category,
zone: data.zone?.trim() || null,
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
source: data.source?.trim() || null,
notes: data.notes?.trim() || null,
+ groupId: data.groupId || null,
+ imageUrl,
},
});
diff --git a/src/app/api/tasks/[id]/complete/route.ts b/src/app/api/tasks/[id]/complete/route.ts
new file mode 100644
index 0000000..7efbceb
--- /dev/null
+++ b/src/app/api/tasks/[id]/complete/route.ts
@@ -0,0 +1,58 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { db } from "@/lib/db";
+import { requireAuth } from "@/lib/auth";
+import { nextDueDate } from "@/lib/tasks/schedule";
+
+const schema = z.object({ notes: z.string().optional() });
+
+export async function POST(req: Request, { params }: { params: { id: string } }) {
+ try {
+ const session = await requireAuth();
+ const task = await db.task.findUnique({ where: { id: params.id } });
+ if (!task || !task.active) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const body = await req.json().catch(() => ({}));
+ const data = schema.parse(body);
+
+ const now = new Date();
+ const next = nextDueDate(task.recurrence, now, task.intervalDays, task.month);
+
+ // Record the completion and advance the due date in one transaction.
+ const [completion] = await db.$transaction([
+ db.taskCompletion.create({
+ data: {
+ taskId: task.id,
+ doneAt: now,
+ notes: data.notes?.trim() || null,
+ actorId: session.user.id,
+ },
+ }),
+ db.task.update({
+ where: { id: task.id },
+ data: {
+ lastDone: now,
+ nextDue: next ?? task.nextDue,
+ // Deactivate one-time tasks once completed.
+ active: task.recurrence === "ONCE" ? false : true,
+ },
+ }),
+ ]);
+
+ await db.auditEvent.create({
+ data: {
+ action: "task.complete",
+ entityId: task.id,
+ actorId: session.user.id,
+ payload: { title: task.title, nextDue: next },
+ },
+ });
+
+ return NextResponse.json({ completion, nextDue: next });
+ } catch (err) {
+ if (err instanceof z.ZodError) return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
+ if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ console.error(err);
+ return NextResponse.json({ error: "Server error" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts
new file mode 100644
index 0000000..6ce06ab
--- /dev/null
+++ b/src/app/api/tasks/route.ts
@@ -0,0 +1,53 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { db } from "@/lib/db";
+import { requireAuth } from "@/lib/auth";
+import { nextDueDate } from "@/lib/tasks/schedule";
+
+const createSchema = z.object({
+ title: z.string().min(1),
+ notes: z.string().optional(),
+ category: z.enum(["GARDEN", "HOME", "EQUIPMENT", "OTHER"]),
+ plantId: z.string().optional(),
+ recurrence: z.enum(["ONCE", "CUSTOM", "MONTHLY", "YEARLY"]),
+ intervalDays: z.number().int().positive().optional(),
+ month: z.number().int().min(1).max(12).optional(),
+ nextDue: z.string(), // ISO date string
+});
+
+export async function POST(req: Request) {
+ try {
+ const session = await requireAuth();
+ const body = await req.json();
+ const data = createSchema.parse(body);
+
+ const task = await db.task.create({
+ data: {
+ title: data.title.trim(),
+ notes: data.notes?.trim() || null,
+ category: data.category,
+ plantId: data.plantId || null,
+ recurrence: data.recurrence,
+ intervalDays: data.intervalDays ?? null,
+ month: data.month ?? null,
+ nextDue: new Date(data.nextDue),
+ },
+ });
+
+ await db.auditEvent.create({
+ data: {
+ action: "task.create",
+ entityId: task.id,
+ actorId: session.user.id,
+ payload: { title: task.title, recurrence: task.recurrence },
+ },
+ });
+
+ return NextResponse.json(task, { status: 201 });
+ } catch (err) {
+ if (err instanceof z.ZodError) return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
+ if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ console.error(err);
+ return NextResponse.json({ error: "Server error" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/zones/[zone]/log/route.ts b/src/app/api/zones/[zone]/log/route.ts
new file mode 100644
index 0000000..45636f4
--- /dev/null
+++ b/src/app/api/zones/[zone]/log/route.ts
@@ -0,0 +1,58 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { db } from "@/lib/db";
+import { requireAuth } from "@/lib/auth";
+
+const schema = z.object({
+ type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
+ notes: z.string().optional(),
+ quantity: z.string().optional(),
+ date: z.string().optional(),
+});
+
+export async function POST(req: Request, { params }: { params: { zone: string } }) {
+ try {
+ const session = await requireAuth();
+ const zoneName = decodeURIComponent(params.zone);
+
+ const plants = await db.plant.findMany({
+ where: { zone: zoneName, active: true },
+ select: { id: true },
+ });
+
+ if (plants.length === 0) return NextResponse.json({ error: "No plants found in this zone" }, { status: 404 });
+
+ const data = schema.parse(await req.json());
+ const date = data.date ? new Date(data.date) : new Date();
+
+ const logs = await db.$transaction(
+ plants.map((p) =>
+ db.plantLog.create({
+ data: {
+ plantId: p.id,
+ type: data.type,
+ date,
+ notes: data.notes?.trim() || null,
+ quantity: data.quantity?.trim() || null,
+ actorId: session.user.id,
+ },
+ })
+ )
+ );
+
+ await db.auditEvent.create({
+ data: {
+ action: "zone.log",
+ actorId: session.user.id,
+ payload: { zone: zoneName, type: data.type, plantCount: logs.length },
+ },
+ });
+
+ return NextResponse.json({ count: logs.length }, { status: 201 });
+ } catch (err) {
+ if (err instanceof z.ZodError) return NextResponse.json({ error: err.issues[0]?.message }, { status: 400 });
+ if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ console.error(err);
+ return NextResponse.json({ error: "Server error" }, { status: 500 });
+ }
+}
diff --git a/src/components/garden/add-plant-button.tsx b/src/components/garden/add-plant-button.tsx
index a370126..21337bd 100644
--- a/src/components/garden/add-plant-button.tsx
+++ b/src/components/garden/add-plant-button.tsx
@@ -14,24 +14,32 @@ import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
-import { Plus, Leaf } from "lucide-react";
+import { Plus, Leaf, MapPin, X } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
import { searchPlants, type PlantSuggestion } from "@/lib/garden/plant-db";
+type Group = { id: string; name: string };
+
const BLANK = {
commonName: "",
species: "",
variety: "",
category: "CANOPY_TREE" as PlantCategory,
- zone: "",
plantedAt: "",
source: "",
notes: "",
+ groupId: "",
+ newGroupName: "",
};
-export function AddPlantButton() {
+export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[]; zones?: string[] }) {
const [open, setOpen] = useState(false);
const [fields, setFields] = useState(BLANK);
+ const [selectedZones, setSelectedZones] = useState([]);
+ const [newZoneName, setNewZoneName] = useState("");
+ const [addingZone, setAddingZone] = useState(false);
+ const [creatingGroup, setCreatingGroup] = useState(false);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [suggestions, setSuggestions] = useState([]);
@@ -39,10 +47,27 @@ export function AddPlantButton() {
const router = useRouter();
const { toast } = useToast();
+ // All zones including newly typed ones not yet in the DB
+ const allZones = Array.from(new Set([...zones, ...selectedZones]));
+
function set(key: keyof typeof BLANK, value: string) {
setFields((f) => ({ ...f, [key]: value }));
}
+ function toggleZone(zone: string) {
+ setSelectedZones((prev) =>
+ prev.includes(zone) ? prev.filter((z) => z !== zone) : [...prev, zone]
+ );
+ }
+
+ function addNewZone() {
+ const name = newZoneName.trim();
+ if (!name) return;
+ if (!selectedZones.includes(name)) setSelectedZones((prev) => [...prev, name]);
+ setNewZoneName("");
+ setAddingZone(false);
+ }
+
function onNameChange(value: string) {
set("commonName", value);
const results = searchPlants(value);
@@ -65,9 +90,13 @@ export function AddPlantButton() {
function handleClose() {
setOpen(false);
setFields(BLANK);
+ setSelectedZones([]);
+ setNewZoneName("");
+ setAddingZone(false);
setError("");
setSuggestions([]);
setShowSuggestions(false);
+ setCreatingGroup(false);
}
async function handleSubmit(e: React.FormEvent) {
@@ -78,30 +107,57 @@ export function AddPlantButton() {
}
setError("");
setBusy(true);
- const res = await fetch("/api/plants", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- commonName: fields.commonName.trim(),
- species: fields.species.trim() || undefined,
- variety: fields.variety.trim() || undefined,
- category: fields.category,
- zone: fields.zone.trim() || undefined,
- plantedAt: fields.plantedAt || undefined,
- source: fields.source.trim() || undefined,
- notes: fields.notes.trim() || undefined,
- }),
- });
+
+ // Create group first if needed.
+ let groupId = fields.groupId || undefined;
+ if (fields.newGroupName.trim()) {
+ const gRes = await fetch("/api/plant-groups", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: fields.newGroupName.trim() }),
+ });
+ if (gRes.ok) groupId = (await gRes.json()).id;
+ }
+
+ const base = {
+ commonName: fields.commonName.trim(),
+ species: fields.species.trim() || undefined,
+ variety: fields.variety.trim() || undefined,
+ category: fields.category,
+ plantedAt: fields.plantedAt || undefined,
+ source: fields.source.trim() || undefined,
+ notes: fields.notes.trim() || undefined,
+ groupId,
+ };
+
+ // Create one plant per selected zone (or one with no zone if none selected).
+ const zonesToCreate = selectedZones.length > 0 ? selectedZones : [undefined];
+ const results = await Promise.all(
+ zonesToCreate.map((zone) =>
+ fetch("/api/plants", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ...base, zone }),
+ })
+ )
+ );
+
setBusy(false);
- if (!res.ok) {
- const err = await res.json().catch(() => ({}));
- toast({ title: "Error", description: err.error ?? "Could not save plant.", variant: "destructive" });
+ const failed = results.filter((r) => !r.ok);
+ if (failed.length > 0) {
+ toast({ title: "Error", description: "Could not save one or more plants.", variant: "destructive" });
return;
}
- const plant = await res.json();
- toast({ title: "Plant added!", description: `${fields.commonName} is now in your garden.` });
+
+ const count = zonesToCreate.length;
+ toast({
+ title: "Plant added!",
+ description: count > 1
+ ? `Added ${fields.commonName} to ${count} locations.`
+ : `${fields.commonName} is now in your garden.`,
+ });
+
handleClose();
- router.push(`/garden/${plant.id}`);
router.refresh();
}
@@ -174,13 +230,8 @@ export function AddPlantButton() {
-
+ {/* Multi-location picker */}
-
-
set("zone", e.target.value)}
- />
+
+
+ {/* Selected zones as chips */}
+ {selectedZones.length > 0 && (
+
+ {selectedZones.map((z) => (
+
+
+ {z}
+
+
+ ))}
+
+ )}
+
+ {/* Existing zones as checkboxes */}
+ {allZones.length > 0 && (
+
+ {allZones.map((z) => (
+
+ ))}
+
+ )}
+
+ {/* Add new zone */}
+ {addingZone ? (
+
+ setNewZoneName(e.target.value)}
+ onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewZone(); } }}
+ autoFocus
+ />
+
+
+
+ ) : (
+
+ )}
@@ -224,13 +337,50 @@ export function AddPlantButton() {
-
- set("source", e.target.value)}
- />
+
+ set("source", v)}>
+
+
+ We planted it
+ Already here when we moved in
+ Gift or trade
+ Self-seeded
+
+
+
+
+
+
+ {!creatingGroup ? (
+
{
+ if (v === "__new__") { setCreatingGroup(true); set("groupId", ""); }
+ else { set("groupId", v); }
+ }}
+ >
+
+
+ No group
+ {groups.map((g) => (
+ {g.name}
+ ))}
+ + Create new group…
+
+
+ ) : (
+
+ set("newGroupName", e.target.value)}
+ autoFocus
+ />
+
+
+ )}
@@ -248,7 +398,7 @@ export function AddPlantButton() {
diff --git a/src/components/garden/edit-plant-button.tsx b/src/components/garden/edit-plant-button.tsx
index 15170ac..4ce2f60 100644
--- a/src/components/garden/edit-plant-button.tsx
+++ b/src/components/garden/edit-plant-button.tsx
@@ -37,8 +37,10 @@ function toDateInput(d: Date | null): string {
return new Date(d).toISOString().split("T")[0];
}
-export function EditPlantButton({ plant }: { plant: Plant }) {
+export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Plant; zones?: string[]; groups?: { id: string; name: string }[] }) {
const [open, setOpen] = useState(false);
+ const [creatingZone, setCreatingZone] = useState(false);
+ const [newZoneName, setNewZoneName] = useState("");
const router = useRouter();
const { toast } = useToast();
@@ -57,6 +59,7 @@ export function EditPlantButton({ plant }: { plant: Plant }) {
});
async function onSubmit(values: FormValues) {
+ if (newZoneName.trim()) values.zone = newZoneName.trim();
const res = await fetch(`/api/plants/${plant.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -123,8 +126,39 @@ export function EditPlantButton({ plant }: { plant: Plant }) {
-
-
+
+ {!creatingZone ? (
+
{
+ if (v === "__new__") { setCreatingZone(true); form.setValue("zone", ""); }
+ else { form.setValue("zone", v); setNewZoneName(""); }
+ }}
+ >
+
+
+
+
+ No location
+ {zones.map((z) => (
+ {z}
+ ))}
+ + Add new location…
+
+
+ ) : (
+
+ setNewZoneName(e.target.value)}
+ autoFocus
+ />
+
+
+ )}
@@ -133,8 +167,21 @@ export function EditPlantButton({ plant }: { plant: Plant }) {
-
-
+
+ form.setValue("source", v)}
+ >
+
+
+
+
+ We planted it
+ Already here when we moved in
+ Gift or trade
+ Self-seeded
+
+
diff --git a/src/components/garden/garden-grid.tsx b/src/components/garden/garden-grid.tsx
new file mode 100644
index 0000000..0da098c
--- /dev/null
+++ b/src/components/garden/garden-grid.tsx
@@ -0,0 +1,306 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Leaf, MapPin, CalendarDays, ChevronDown, ChevronRight, Users } from "lucide-react";
+import { formatDate } from "@/lib/utils";
+import { CATEGORY_LABELS } from "@/lib/garden/categories";
+import { GroupLogButton } from "@/components/garden/group-log-button";
+import { ZoneLogButton } from "@/components/garden/zone-log-button";
+
+type Plant = {
+ id: string;
+ commonName: string;
+ variety: string | null;
+ species: string | null;
+ category: string;
+ zone: string | null;
+ plantedAt: Date | null;
+ imageUrl: string | null;
+ group: { id: string; name: string } | null;
+ _count: { logs: number };
+ logs: { date: Date; type: string }[];
+};
+
+type Group = {
+ id: string;
+ name: string;
+ _count: { plants: number };
+};
+
+type ViewMode = "type" | "location";
+
+// Cluster plants that share the same commonName + variety into one row.
+function clusterByVariety(plants: Plant[]) {
+ const map = new Map
();
+ for (const p of plants) {
+ const key = `${p.commonName}||${p.variety ?? ""}`;
+ if (!map.has(key)) map.set(key, []);
+ map.get(key)!.push(p);
+ }
+ return Array.from(map.values());
+}
+
+export function GardenGrid({ plants, groups }: { plants: Plant[]; groups: Group[] }) {
+ const [expanded, setExpanded] = useState>(new Set());
+ const [view, setView] = useState("type");
+
+ function toggle(id: string) {
+ setExpanded((prev) => {
+ const next = new Set(prev);
+ next.has(id) ? next.delete(id) : next.add(id);
+ return next;
+ });
+ }
+
+ if (plants.length === 0) {
+ return (
+
+
+
No plants yet
+
Add your first plant to start building your food forest registry.
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {view === "type" ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function TypeView({ plants, groups, expanded, toggle }: {
+ plants: Plant[];
+ groups: Group[];
+ expanded: Set;
+ toggle: (id: string) => void;
+}) {
+ const ungrouped = plants.filter((p) => !p.group);
+ const grouped = groups.map((g) => ({
+ ...g,
+ plants: plants.filter((p) => p.group?.id === g.id),
+ })).filter((g) => g.plants.length > 0);
+
+ return (
+
+ {grouped.map((g) => {
+ const isOpen = expanded.has(g.id);
+ const clusters = clusterByVariety(g.plants);
+ return (
+
+
+
+
+
+ {isOpen ? (
+
+ {clusters.map((cluster) => (
+
+ ))}
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+ {ungrouped.length > 0 && (
+
+ {grouped.length > 0 &&
Other plants
}
+
+ {clusterByVariety(ungrouped).map((cluster) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+// A single variety that may span multiple locations.
+function VarietyRow({ plants, expanded, toggle }: {
+ plants: Plant[];
+ expanded: Set;
+ toggle: (id: string) => void;
+}) {
+ const rep = plants[0];
+ const key = `variety||${rep.commonName}||${rep.variety ?? ""}`;
+ const isOpen = expanded.has(key);
+
+ // Collapsible row with location chips.
+ return (
+
+
+
+ {isOpen && (
+
+ {plants.map((p) =>
)}
+
+ )}
+
+ );
+}
+
+function LocationView({ plants, expanded, toggle }: {
+ plants: Plant[];
+ expanded: Set;
+ toggle: (id: string) => void;
+}) {
+ const zones = Array.from(new Set(plants.filter((p) => p.zone).map((p) => p.zone!))).sort();
+ const noZone = plants.filter((p) => !p.zone);
+
+ return (
+
+ {zones.map((zone) => {
+ const zonePlants = plants.filter((p) => p.zone === zone);
+ const isOpen = expanded.has(zone);
+ return (
+
+
+
+
+
+ {isOpen ? (
+
+ {zonePlants.map((p) =>
)}
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+ {noZone.length > 0 && (
+
+ {zones.length > 0 &&
No location set
}
+
+ {noZone.map((p) =>
)}
+
+
+ )}
+
+ );
+}
+
+function PlantCard({ plant }: { plant: Plant }) {
+ return (
+
+
+ {plant.imageUrl && (
+
+

+
+ )}
+ {!plant.imageUrl && (
+
+
+
+ )}
+
+
+
+
+ {plant.commonName}
+ {plant.variety && · {plant.variety}}
+
+ {plant.species &&
{plant.species}
}
+
+
+ {CATEGORY_LABELS[plant.category as keyof typeof CATEGORY_LABELS]}
+
+
+
+
+ {plant.zone && (
+
+
+ {plant.zone}
+
+ )}
+ {plant.plantedAt && (
+
+
+ Planted {formatDate(plant.plantedAt)}
+
+ )}
+ {plant.logs[0] && (
+
+
+ Last {plant.logs[0].type.toLowerCase()} {formatDate(plant.logs[0].date)}
+
+ )}
+
+
+
+ {plant._count.logs} log {plant._count.logs === 1 ? "entry" : "entries"}
+
+
+
+
+ );
+}
diff --git a/src/components/garden/group-log-button.tsx b/src/components/garden/group-log-button.tsx
new file mode 100644
index 0000000..7e41c25
--- /dev/null
+++ b/src/components/garden/group-log-button.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+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 { ClipboardList } from "lucide-react";
+import { LOG_TYPE_LABELS } from "@/lib/garden/categories";
+
+const LOG_TYPES = ["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT"] as const;
+
+export function GroupLogButton({ groupId, groupName }: { groupId: string; groupName: string }) {
+ const [open, setOpen] = useState(false);
+ const [type, setType] = useState("CARE");
+ const [notes, setNotes] = useState("");
+ const [busy, setBusy] = useState(false);
+ const router = useRouter();
+ const { toast } = useToast();
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setBusy(true);
+ const res = await fetch(`/api/plant-groups/${groupId}/log`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ type, notes: notes.trim() || undefined }),
+ });
+ setBusy(false);
+ if (!res.ok) {
+ toast({ title: "Error", description: "Could not save log entry.", variant: "destructive" });
+ return;
+ }
+ const data = await res.json();
+ toast({ title: "Logged!", description: `Added to all ${data.count} ${groupName.toLowerCase()}.` });
+ setNotes("");
+ setOpen(false);
+ router.refresh();
+ }
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/src/components/garden/zone-log-button.tsx b/src/components/garden/zone-log-button.tsx
new file mode 100644
index 0000000..c02f7d6
--- /dev/null
+++ b/src/components/garden/zone-log-button.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+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 { ClipboardList } from "lucide-react";
+import { LOG_TYPE_LABELS } from "@/lib/garden/categories";
+
+const LOG_TYPES = ["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT"] as const;
+
+export function ZoneLogButton({ zone, plantCount }: { zone: string; plantCount: number }) {
+ const [open, setOpen] = useState(false);
+ const [type, setType] = useState("CARE");
+ const [notes, setNotes] = useState("");
+ const [busy, setBusy] = useState(false);
+ const router = useRouter();
+ const { toast } = useToast();
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setBusy(true);
+ const res = await fetch(`/api/zones/${encodeURIComponent(zone)}/log`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ type, notes: notes.trim() || undefined }),
+ });
+ setBusy(false);
+ if (!res.ok) {
+ toast({ title: "Error", description: "Could not save log entry.", variant: "destructive" });
+ return;
+ }
+ const data = await res.json();
+ toast({ title: "Logged!", description: `Added to all ${data.count} plants in ${zone}.` });
+ setNotes("");
+ setOpen(false);
+ router.refresh();
+ }
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx
index 449079d..6269183 100644
--- a/src/components/layout/sidebar.tsx
+++ b/src/components/layout/sidebar.tsx
@@ -2,12 +2,13 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
-import { Leaf, Home, Wrench, LayoutDashboard } from "lucide-react";
+import { Leaf, Home, Wrench, LayoutDashboard, Bell } from "lucide-react";
import { cn } from "@/lib/utils";
const navItems = [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ label: "Garden", href: "/garden", icon: Leaf },
+ { label: "Reminders", href: "/tasks", icon: Bell },
// Home maintenance and Equipment come in future releases
// { label: "Home", href: "/home", icon: Home },
// { label: "Equipment", href: "/equipment", icon: Wrench },
diff --git a/src/components/tasks/add-task-button.tsx b/src/components/tasks/add-task-button.tsx
new file mode 100644
index 0000000..be8e41c
--- /dev/null
+++ b/src/components/tasks/add-task-button.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+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, Bell } from "lucide-react";
+import { MONTH_NAMES } from "@/lib/tasks/schedule";
+
+const BLANK = {
+ title: "",
+ notes: "",
+ category: "GARDEN",
+ plantId: "",
+ recurrence: "ONCE",
+ intervalDays: "30",
+ month: "1",
+ nextDue: new Date().toISOString().slice(0, 10),
+};
+
+export function AddTaskButton() {
+ const [open, setOpen] = useState(false);
+ const [fields, setFields] = useState({ ...BLANK });
+ const [busy, setBusy] = useState(false);
+ const router = useRouter();
+ const { toast } = useToast();
+
+ function set(key: keyof typeof BLANK, val: string) {
+ setFields((f) => ({ ...f, [key]: val }));
+ }
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!fields.title.trim()) {
+ toast({ title: "Title is required", variant: "destructive" });
+ return;
+ }
+
+ const body: Record = {
+ title: fields.title.trim(),
+ notes: fields.notes.trim() || undefined,
+ category: fields.category,
+ recurrence: fields.recurrence,
+ nextDue: new Date(fields.nextDue).toISOString(),
+ };
+ if (fields.plantId.trim()) body.plantId = fields.plantId.trim();
+ if (fields.recurrence === "CUSTOM") body.intervalDays = parseInt(fields.intervalDays);
+ if (fields.recurrence === "YEARLY") body.month = parseInt(fields.month);
+
+ setBusy(true);
+ const res = await fetch("/api/tasks", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ setBusy(false);
+
+ if (!res.ok) {
+ const json = await res.json().catch(() => ({}));
+ toast({ title: "Error", description: json.error ?? "Could not save reminder.", variant: "destructive" });
+ return;
+ }
+
+ toast({ title: "Reminder added!" });
+ setFields({ ...BLANK });
+ setOpen(false);
+ router.refresh();
+ }
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/src/components/tasks/complete-task-button.tsx b/src/components/tasks/complete-task-button.tsx
new file mode 100644
index 0000000..41b9241
--- /dev/null
+++ b/src/components/tasks/complete-task-button.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { useToast } from "@/hooks/use-toast";
+import { CheckCircle2 } from "lucide-react";
+
+interface Props {
+ taskId: string;
+ taskTitle: string;
+}
+
+export function CompleteTaskButton({ taskId, taskTitle }: Props) {
+ const [busy, setBusy] = useState(false);
+ const router = useRouter();
+ const { toast } = useToast();
+
+ async function markDone() {
+ setBusy(true);
+ const res = await fetch(`/api/tasks/${taskId}/complete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
+ setBusy(false);
+ if (!res.ok) {
+ toast({ title: "Error", description: "Could not mark as done.", variant: "destructive" });
+ return;
+ }
+ toast({ title: "Done!", description: `"${taskTitle}" marked complete.` });
+ router.refresh();
+ }
+
+ return (
+
+ );
+}
diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts
index 432118d..8bff247 100644
--- a/src/lib/changelog.ts
+++ b/src/lib/changelog.ts
@@ -8,6 +8,79 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
+ {
+ version: "0.5.1",
+ date: "2026-06-15",
+ changes: [
+ "Plant database expanded with species-level entries for irises (bearded, Siberian, yellow flag, Japanese, Dutch), daylilies, hostas, hydrangeas (4 species), coneflowers, milkweeds, bee balms, rudbeckias, phlox, goldenrod, oregano types, and more. Type \"iris\" and get the actual species to choose from.",
+ ],
+ },
+ {
+ version: "0.5.0",
+ date: "2026-06-15",
+ changes: [
+ "Plant photos — when you add a plant, the app automatically pulls a photo from Wikipedia using the scientific name (or common name as a fallback). Photos show on the garden cards and the plant detail page. Plants without a photo get a green leaf placeholder.",
+ ],
+ },
+ {
+ version: "0.4.5",
+ date: "2026-06-15",
+ changes: [
+ "All plants in the \"By type\" view now use the same collapsible row format — consistent look whether a plant is in one location or several.",
+ ],
+ },
+ {
+ version: "0.4.4",
+ date: "2026-06-15",
+ changes: [
+ "In the \"By type\" view, plants with the same name and variety now collapse into a single row showing their locations as chips. Click to expand and see each individual bed's card.",
+ ],
+ },
+ {
+ version: "0.4.3",
+ date: "2026-06-15",
+ changes: [
+ "Add a plant to multiple locations at once — tick as many beds as you like and the app creates a separate entry for each. The button even shows \"Add to 3 locations\" so you know what's about to happen.",
+ ],
+ },
+ {
+ version: "0.4.2",
+ date: "2026-06-15",
+ changes: [
+ "Location picker — \"Location on your property\" is now a dropdown like groups. Pick an existing location or type a new one; no more typo mismatches between plants in the same bed.",
+ ],
+ },
+ {
+ version: "0.4.1",
+ date: "2026-06-15",
+ changes: [
+ "Garden now has a \"By location\" view — switch between viewing plants by type (groups) or by garden bed/location. Each location collapses just like groups, with its own \"Log all\" button to log care for everything in that bed at once.",
+ ],
+ },
+ {
+ version: "0.4.0",
+ date: "2026-06-15",
+ changes: [
+ "Plant groups — group multiple plants together (like all your irises) so they show collapsed on the garden page and you can log care for the whole group at once with one tap.",
+ "When adding a plant, pick an existing group or create a new one on the spot.",
+ ],
+ },
+ {
+ version: "0.3.1",
+ date: "2026-06-15",
+ changes: [
+ "Plants now have a \"Where did it come from?\" dropdown: We planted it, Already here when we moved in, Gift or trade, or Self-seeded. Great for tracking what was on the property before you.",
+ ],
+ },
+ {
+ version: "0.3.0",
+ date: "2026-06-15",
+ changes: [
+ "Reminders — new section in the sidebar for tracking recurring tasks: watering schedules, fertilizing, pruning, HVAC filters, smoke alarm batteries, and anything else that needs doing on a schedule.",
+ "Set a reminder to happen just once, every N days, every month, or every year. Mark any reminder done and the next due date updates automatically.",
+ "Reminders can optionally be linked to a specific plant in your garden.",
+ ],
+ },
{
version: "0.2.0",
date: "2026-06-15",
diff --git a/src/lib/garden/plant-db.ts b/src/lib/garden/plant-db.ts
index 68297d5..27b8320 100644
--- a/src/lib/garden/plant-db.ts
+++ b/src/lib/garden/plant-db.ts
@@ -94,7 +94,7 @@ export const PLANT_DB: PlantSuggestion[] = [
{ commonName: "Catnip", species: "Nepeta cataria", category: "HERB" },
{ commonName: "Valerian", species: "Valeriana officinalis", category: "HERB" },
{ commonName: "St. John's wort", species: "Hypericum perforatum", category: "HERB" },
- { commonName: "Echinacea / Coneflower", species: "Echinacea purpurea", category: "HERB" },
+ { commonName: "Chamomile, Roman", species: "Chamaemelum nobile", category: "HERB" },
// ---------------------------------------------------------------------------
// Groundcovers
@@ -137,48 +137,115 @@ export const PLANT_DB: PlantSuggestion[] = [
// ---------------------------------------------------------------------------
// Perennial flowers
// ---------------------------------------------------------------------------
- { commonName: "Coneflower", species: "Echinacea purpurea", category: "PERENNIAL_FLOWER" },
- { commonName: "Black-eyed Susan", species: "Rudbeckia hirta", category: "PERENNIAL_FLOWER" },
- { commonName: "Bee balm", species: "Monarda didyma", category: "PERENNIAL_FLOWER" },
- { commonName: "Wild bergamot", species: "Monarda fistulosa", category: "PERENNIAL_FLOWER" },
{ commonName: "Peony", species: "Paeonia lactiflora", category: "PERENNIAL_FLOWER" },
- { commonName: "Hosta", species: "Hosta spp.", category: "PERENNIAL_FLOWER" },
- { commonName: "Daylily", species: "Hemerocallis spp.", category: "PERENNIAL_FLOWER" },
- { commonName: "Iris", species: "Iris spp.", category: "PERENNIAL_FLOWER" },
- { commonName: "Salvia, perennial", species: "Salvia nemorosa", category: "PERENNIAL_FLOWER" },
- { commonName: "Catmint", species: "Nepeta × faassenii", category: "PERENNIAL_FLOWER" },
- { commonName: "Astilbe", species: "Astilbe spp.", category: "PERENNIAL_FLOWER" },
- { commonName: "Coreopsis", species: "Coreopsis spp.", category: "PERENNIAL_FLOWER" },
- { commonName: "Lupine", species: "Lupinus spp.", category: "PERENNIAL_FLOWER" },
- { commonName: "Delphinium", species: "Delphinium spp.", category: "PERENNIAL_FLOWER" },
- { commonName: "Hollyhock", species: "Alcea rosea", category: "PERENNIAL_FLOWER" },
- { commonName: "Foxglove", species: "Digitalis purpurea", category: "PERENNIAL_FLOWER" },
- { commonName: "Phlox", species: "Phlox paniculata", category: "PERENNIAL_FLOWER" },
- { commonName: "Sedum / Stonecrop", species: "Sedum spp.", category: "PERENNIAL_FLOWER" },
- { commonName: "Russian sage", species: "Perovskia atriplicifolia", category: "PERENNIAL_FLOWER" },
- { commonName: "Goldenrod", species: "Solidago spp.", category: "PERENNIAL_FLOWER" },
- { commonName: "Joe Pye weed", species: "Eutrochium purpureum", category: "PERENNIAL_FLOWER" },
- { commonName: "Bleeding heart", species: "Lamprocapnos spectabilis", category: "PERENNIAL_FLOWER" },
- { commonName: "Columbine", species: "Aquilegia spp.", category: "PERENNIAL_FLOWER" },
- { commonName: "Coral bells", species: "Heuchera spp.", category: "PERENNIAL_FLOWER" },
- { commonName: "Liatris / Blazing star", species: "Liatris spicata", category: "PERENNIAL_FLOWER" },
- { commonName: "Milkweed", species: "Asclepias spp.", category: "PERENNIAL_FLOWER" },
- { commonName: "Butterfly weed", species: "Asclepias tuberosa", category: "PERENNIAL_FLOWER" },
- { commonName: "Monkshood", species: "Aconitum spp.", category: "PERENNIAL_FLOWER" },
- { commonName: "Prairie dropseed", species: "Sporobolus heterolepis", category: "PERENNIAL_FLOWER" },
- { commonName: "Coneflower, yellow", species: "Ratibida pinnata", category: "PERENNIAL_FLOWER" },
- { commonName: "False indigo", species: "Baptisia australis", category: "PERENNIAL_FLOWER" },
- { commonName: "Shasta daisy", species: "Leucanthemum × superbum", category: "PERENNIAL_FLOWER" },
- { commonName: "Spiderwort", species: "Tradescantia ohiensis", category: "PERENNIAL_FLOWER" },
- { commonName: "Tickseed", species: "Coreopsis tinctoria", category: "PERENNIAL_FLOWER" },
- { commonName: "Rose mallow", species: "Hibiscus moscheutos", category: "PERENNIAL_FLOWER" },
- { commonName: "Obedient plant", species: "Physostegia virginiana", category: "PERENNIAL_FLOWER" },
- { commonName: "Globe thistle", species: "Echinops spp.", category: "PERENNIAL_FLOWER" },
- { commonName: "Agastache", species: "Agastache spp.", category: "PERENNIAL_FLOWER" },
+ // Irises — species level
+ { commonName: "Iris, bearded (purple)", species: "Iris germanica", category: "PERENNIAL_FLOWER" },
+ { commonName: "Iris, Siberian", species: "Iris sibirica", category: "PERENNIAL_FLOWER" },
+ { commonName: "Iris, yellow flag", species: "Iris pseudacorus", category: "PERENNIAL_FLOWER" },
+ { commonName: "Iris, Japanese", species: "Iris ensata", category: "PERENNIAL_FLOWER" },
+ { commonName: "Iris, Dutch", species: "Iris × hollandica", category: "PERENNIAL_FLOWER" },
+ { commonName: "Iris, dwarf bearded", species: "Iris pumila", category: "PERENNIAL_FLOWER" },
+ { commonName: "Iris, Louisiana", species: "Iris fulva", category: "PERENNIAL_FLOWER" },
+
+ // Daylilies — species level
+ { commonName: "Daylily, tawny (orange)", species: "Hemerocallis fulva", category: "PERENNIAL_FLOWER" },
+ { commonName: "Daylily, lemon", species: "Hemerocallis lilioasphodelus", category: "PERENNIAL_FLOWER" },
+ { commonName: "Daylily, hybrid", species: "Hemerocallis × hybrida", category: "PERENNIAL_FLOWER" },
+
+ // Hostas — species level
+ { commonName: "Hosta, plantain lily", species: "Hosta plantaginea", category: "PERENNIAL_FLOWER" },
+ { commonName: "Hosta, siebold", species: "Hosta sieboldiana", category: "PERENNIAL_FLOWER" },
+ { commonName: "Hosta, hybrid", species: "Hosta × hybrida", category: "PERENNIAL_FLOWER" },
+
+ // Rudbeckia / daisy types
+ { commonName: "Black-eyed Susan, perennial", species: "Rudbeckia fulgida", category: "PERENNIAL_FLOWER", notes: "Spreads into large clumps over time." },
+ { commonName: "Black-eyed Susan, annual", species: "Rudbeckia hirta", category: "ANNUAL_FLOWER" },
+ { commonName: "Cutleaf coneflower", species: "Rudbeckia laciniata", category: "PERENNIAL_FLOWER" },
+ { commonName: "Sweet coneflower", species: "Rudbeckia subtomentosa", category: "PERENNIAL_FLOWER" },
+ { commonName: "False sunflower", species: "Heliopsis helianthoides", category: "PERENNIAL_FLOWER" },
+ { commonName: "Sneezeweed", species: "Helenium autumnale", category: "PERENNIAL_FLOWER" },
+ { commonName: "Shasta daisy", species: "Leucanthemum × superbum", category: "PERENNIAL_FLOWER" },
+ { commonName: "Ox-eye daisy", species: "Leucanthemum vulgare", category: "PERENNIAL_FLOWER" },
+
+ // Hydrangeas — species level
+ { commonName: "Hydrangea, bigleaf", species: "Hydrangea macrophylla", category: "LARGE_SHRUB" },
+ { commonName: "Hydrangea, panicle", species: "Hydrangea paniculata", category: "LARGE_SHRUB" },
+ { commonName: "Hydrangea, smooth", species: "Hydrangea arborescens", category: "LARGE_SHRUB" },
+ { commonName: "Hydrangea, oakleaf", species: "Hydrangea quercifolia", category: "LARGE_SHRUB" },
+ { commonName: "Hydrangea, climbing", species: "Hydrangea petiolaris", category: "VINE" },
+
+ // Echinacea — species level
+ { commonName: "Coneflower, purple", species: "Echinacea purpurea", category: "PERENNIAL_FLOWER" },
+ { commonName: "Coneflower, pale", species: "Echinacea pallida", category: "PERENNIAL_FLOWER" },
+ { commonName: "Coneflower, narrow-leaf", species: "Echinacea angustifolia", category: "PERENNIAL_FLOWER" },
+ { commonName: "Coneflower, yellow", species: "Ratibida pinnata", category: "PERENNIAL_FLOWER" },
+
+ // Milkweed — species level
+ { commonName: "Milkweed, common", species: "Asclepias syriaca", category: "PERENNIAL_FLOWER" },
+ { commonName: "Milkweed, butterfly", species: "Asclepias tuberosa", category: "PERENNIAL_FLOWER" },
+ { commonName: "Milkweed, swamp", species: "Asclepias incarnata", category: "PERENNIAL_FLOWER" },
+
+ // Bee balm / Monarda
+ { commonName: "Bee balm, scarlet", species: "Monarda didyma", category: "PERENNIAL_FLOWER" },
+ { commonName: "Bee balm, wild / lavender", species: "Monarda fistulosa", category: "PERENNIAL_FLOWER" },
+ { commonName: "Bee balm, spotted", species: "Monarda punctata", category: "PERENNIAL_FLOWER" },
+
+ // Salvia
+ { commonName: "Salvia, perennial", species: "Salvia nemorosa", category: "PERENNIAL_FLOWER" },
+ { commonName: "Salvia, meadow sage", species: "Salvia pratensis", category: "PERENNIAL_FLOWER" },
+
+ // Phlox
+ { commonName: "Phlox, garden", species: "Phlox paniculata", category: "PERENNIAL_FLOWER" },
+ { commonName: "Phlox, creeping", species: "Phlox subulata", category: "GROUNDCOVER" },
+ { commonName: "Phlox, woodland", species: "Phlox divaricata", category: "PERENNIAL_FLOWER" },
+
+ // Sedum
+ { commonName: "Sedum, autumn joy", species: "Hylotelephium spectabile", category: "PERENNIAL_FLOWER" },
+ { commonName: "Sedum, stonecrop", species: "Sedum acre", category: "GROUNDCOVER" },
+
+ // Lily of the valley
+ { commonName: "Lily of the valley", species: "Convallaria majalis", category: "GROUNDCOVER", notes: "Spreads by rhizome — can become aggressive." },
+
+ // Oregano types
+ { commonName: "Oregano, culinary", species: "Origanum vulgare", category: "HERB" },
+ { commonName: "Oregano, flowering", species: "Origanum laevigatum", category: "PERENNIAL_FLOWER", notes: "Ornamental — varieties like Herrenhausen and Hopley's." },
+ { commonName: "Oregano, Greek", species: "Origanum vulgare subsp. hirtum", category: "HERB" },
+
+ // Catmint / Nepeta
+ { commonName: "Catmint", species: "Nepeta × faassenii", category: "PERENNIAL_FLOWER" },
+ { commonName: "Catmint, six hills giant", species: "Nepeta racemosa", category: "PERENNIAL_FLOWER" },
+
+ // Goldenrod
+ { commonName: "Goldenrod, showy", species: "Solidago speciosa", category: "PERENNIAL_FLOWER" },
+ { commonName: "Goldenrod, stiff", species: "Solidago rigida", category: "PERENNIAL_FLOWER" },
+ { commonName: "Goldenrod, wreath", species: "Solidago caesia", category: "PERENNIAL_FLOWER" },
+
+ // Remaining perennial flowers
+ { commonName: "Astilbe", species: "Astilbe spp.", category: "PERENNIAL_FLOWER" },
+ { commonName: "Lupine", species: "Lupinus spp.", category: "PERENNIAL_FLOWER" },
+ { commonName: "Delphinium", species: "Delphinium spp.", category: "PERENNIAL_FLOWER" },
+ { commonName: "Hollyhock", species: "Alcea rosea", category: "PERENNIAL_FLOWER" },
+ { commonName: "Foxglove", species: "Digitalis purpurea", category: "PERENNIAL_FLOWER" },
+ { commonName: "Russian sage", species: "Perovskia atriplicifolia", category: "PERENNIAL_FLOWER" },
+ { commonName: "Joe Pye weed", species: "Eutrochium purpureum", category: "PERENNIAL_FLOWER" },
+ { commonName: "Bleeding heart", species: "Lamprocapnos spectabilis", category: "PERENNIAL_FLOWER" },
+ { commonName: "Columbine", species: "Aquilegia spp.", category: "PERENNIAL_FLOWER" },
+ { commonName: "Coral bells", species: "Heuchera spp.", category: "PERENNIAL_FLOWER" },
+ { commonName: "Liatris / Blazing star", species: "Liatris spicata", category: "PERENNIAL_FLOWER" },
+ { commonName: "Monkshood", species: "Aconitum spp.", category: "PERENNIAL_FLOWER" },
+ { commonName: "Prairie dropseed", species: "Sporobolus heterolepis", category: "PERENNIAL_FLOWER" },
+ { commonName: "False indigo", species: "Baptisia australis", category: "PERENNIAL_FLOWER" },
+ { commonName: "Spiderwort", species: "Tradescantia ohiensis", category: "PERENNIAL_FLOWER" },
+ { commonName: "Rose mallow", species: "Hibiscus moscheutos", category: "PERENNIAL_FLOWER" },
+ { commonName: "Obedient plant", species: "Physostegia virginiana", category: "PERENNIAL_FLOWER" },
+ { commonName: "Globe thistle", species: "Echinops spp.", category: "PERENNIAL_FLOWER" },
+ { commonName: "Agastache", species: "Agastache spp.", category: "PERENNIAL_FLOWER" },
{ commonName: "Gaillardia / Blanket flower", species: "Gaillardia spp.", category: "PERENNIAL_FLOWER" },
- { commonName: "Veronicastrum", species: "Veronicastrum virginicum", category: "PERENNIAL_FLOWER" },
- { commonName: "Turtlehead", species: "Chelone glabra", category: "PERENNIAL_FLOWER" },
- { commonName: "Wild ginger", species: "Asarum canadense", category: "PERENNIAL_FLOWER" },
+ { commonName: "Veronicastrum", species: "Veronicastrum virginicum", category: "PERENNIAL_FLOWER" },
+ { commonName: "Turtlehead", species: "Chelone glabra", category: "PERENNIAL_FLOWER" },
+ { commonName: "Wild ginger", species: "Asarum canadense", category: "PERENNIAL_FLOWER" },
+ { commonName: "Coreopsis, threadleaf", species: "Coreopsis verticillata", category: "PERENNIAL_FLOWER" },
+ { commonName: "Coreopsis, lance-leaf", species: "Coreopsis lanceolata", category: "PERENNIAL_FLOWER" },
// ---------------------------------------------------------------------------
// Annual vegetables
diff --git a/src/lib/garden/plant-image.ts b/src/lib/garden/plant-image.ts
new file mode 100644
index 0000000..05f67cc
--- /dev/null
+++ b/src/lib/garden/plant-image.ts
@@ -0,0 +1,25 @@
+// Fetch a thumbnail image URL from Wikipedia for a given plant.
+// Tries scientific name first, falls back to common name.
+// Returns null if nothing is found or the request fails.
+export async function fetchPlantImageUrl(
+ species?: string | null,
+ commonName?: string | null,
+): Promise {
+ const queries = [species, commonName].filter(Boolean) as string[];
+ for (const query of queries) {
+ const url = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(query)}`;
+ try {
+ const res = await fetch(url, {
+ headers: { "User-Agent": "MoonBase/1.0 (homestead plant tracker)" },
+ signal: AbortSignal.timeout(4000),
+ });
+ if (!res.ok) continue;
+ const data = await res.json();
+ const img = data?.thumbnail?.source ?? data?.originalimage?.source ?? null;
+ if (img) return img;
+ } catch {
+ // network error or timeout — try next query
+ }
+ }
+ return null;
+}
diff --git a/src/lib/tasks/schedule.ts b/src/lib/tasks/schedule.ts
new file mode 100644
index 0000000..c5ea08f
--- /dev/null
+++ b/src/lib/tasks/schedule.ts
@@ -0,0 +1,76 @@
+export type RecurrenceType = "ONCE" | "CUSTOM" | "MONTHLY" | "YEARLY";
+
+// Given a completed task, compute the next due date.
+export function nextDueDate(
+ recurrence: RecurrenceType,
+ completedAt: Date,
+ intervalDays: number | null,
+ month: number | null,
+): Date | null {
+ const d = new Date(completedAt);
+
+ switch (recurrence) {
+ case "ONCE":
+ return null; // no next due — task is done forever
+
+ case "CUSTOM": {
+ const days = intervalDays ?? 30;
+ d.setDate(d.getDate() + days);
+ return d;
+ }
+
+ case "MONTHLY": {
+ // Same day of month, next month
+ d.setMonth(d.getMonth() + 1);
+ return d;
+ }
+
+ case "YEARLY": {
+ // Same month next year — set to the 1st of that month
+ const targetMonth = (month ?? d.getMonth() + 1) - 1; // convert to 0-indexed
+ d.setFullYear(d.getFullYear() + 1);
+ d.setMonth(targetMonth);
+ d.setDate(1);
+ return d;
+ }
+ default:
+ return null;
+ }
+}
+
+// Human-readable description of a recurrence pattern.
+export function describeRecurrence(
+ recurrence: RecurrenceType,
+ intervalDays: number | null,
+ month: number | null,
+): string {
+ switch (recurrence) {
+ case "ONCE": return "One time only";
+ case "CUSTOM": return `Every ${intervalDays ?? 30} days`;
+ case "MONTHLY": return "Every month";
+ case "YEARLY": {
+ if (month) {
+ const monthName = new Date(2000, month - 1, 1).toLocaleString("en-US", { month: "long" });
+ return `Every year in ${monthName}`;
+ }
+ return "Every year";
+ }
+ }
+}
+
+// Is a task overdue?
+export function isOverdue(nextDue: Date): boolean {
+ return new Date(nextDue) < new Date();
+}
+
+// Is a task due within the next N days?
+export function isDueSoon(nextDue: Date, days = 30): boolean {
+ const cutoff = new Date();
+ cutoff.setDate(cutoff.getDate() + days);
+ return new Date(nextDue) <= cutoff;
+}
+
+export const MONTH_NAMES = [
+ "January", "February", "March", "April", "May", "June",
+ "July", "August", "September", "October", "November", "December",
+];
diff --git a/src/lib/version.ts b/src/lib/version.ts
index 7b03def..3d947c0 100644
--- a/src/lib/version.ts
+++ b/src/lib/version.ts
@@ -1,2 +1,2 @@
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
-export const APP_VERSION = "0.2.2";
+export const APP_VERSION = "0.5.1";