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.commonName} +
+ )} + {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() {
- set("category", v)}> + {CATEGORY_ORDER.map((cat) => ( {CATEGORY_LABELS[cat]} @@ -203,14 +254,76 @@ 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)} - /> + + +
+ +
+ + {!creatingGroup ? ( + + ) : ( +
+ 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 ? ( + + ) : ( +
+ setNewZoneName(e.target.value)} + autoFocus + /> + +
+ )}
@@ -133,8 +167,21 @@ export function EditPlantButton({ plant }: { plant: Plant }) {
- - + +
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.commonName} +
+ )} + {!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 ( + <> + + + + + + Log for all {groupName} + +
+
+ + +
+ +
+ +