v0.5.1 — Reminders, plant groups, location views, Wikipedia photos, Docker deploy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bonna Moon
2026-06-15 20:53:03 -05:00
parent e3f6a8ec92
commit 04dec2a198
32 changed files with 1988 additions and 181 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.git
.gitignore
node_modules
.next
.env
.env.*
npm-debug.log*
README.md

31
Dockerfile Normal file
View File

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

30
docker-compose.yml Normal file
View File

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

8
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,8 @@
#!/bin/sh
set -e
echo "Running Prisma migrations..."
npx prisma migrate deploy
echo "Starting Moon Base..."
exec node server.js

View File

@@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
images: {
remotePatterns: [{ protocol: "https", hostname: "**" }],
},

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
-- v0.5.0 — Plant image URL (fetched from Wikipedia on add/edit)
ALTER TABLE "Plant" ADD COLUMN "imageUrl" TEXT;

View File

@@ -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? // 112: 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

View File

@@ -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({
const [plant, allPlants, groups] = await Promise.all([
db.plant.findUnique({
where: { id: params.id },
include: {
logs: { orderBy: { date: "desc" } },
},
});
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 (
<div className="space-y-6 max-w-2xl">
<div className="flex items-center gap-3">
@@ -44,10 +48,16 @@ export default async function PlantDetailPage({ params }: { params: { id: string
</div>
<div className="flex items-center gap-2">
<EditPlantButton plant={plant} />
<EditPlantButton plant={plant} zones={zones} groups={groups} />
<DeletePlantButton plantId={plant.id} plantName={plant.commonName} />
</div>
{plant.imageUrl && (
<div className="rounded-lg overflow-hidden h-56 bg-muted">
<img src={plant.imageUrl} alt={plant.commonName} className="w-full h-full object-cover" />
</div>
)}
<Card>
<CardContent className="pt-4 space-y-2 text-sm">
{plant.zone && (

View File

@@ -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({
const [plants, groups] = await Promise.all([
db.plant.findMany({
where: { active: true },
orderBy: [{ category: "asc" }, { commonName: "asc" }],
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 (
<div className="space-y-6">
@@ -28,70 +33,10 @@ export default async function GardenPage() {
{plants.length} {plants.length === 1 ? "plant" : "plants"} on your acre
</p>
</div>
<AddPlantButton />
<AddPlantButton groups={groups} zones={zones} />
</div>
{plants.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Leaf className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="font-medium">No plants yet</p>
<p className="text-sm mt-1">Add your first plant to start building your food forest registry.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{plants.map((plant) => (
<Link key={plant.id} href={`/garden/${plant.id}`}>
<Card className="hover:shadow-md transition-shadow h-full cursor-pointer">
<CardContent className="pt-4 pb-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium truncate">
{plant.commonName}
{plant.variety && (
<span className="text-muted-foreground font-normal"> · {plant.variety}</span>
)}
</p>
{plant.species && (
<p className="text-xs text-muted-foreground italic truncate">{plant.species}</p>
)}
</div>
<Badge variant="secondary" className="shrink-0 text-xs">
{CATEGORY_LABELS[plant.category]}
</Badge>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
{plant.zone && (
<div className="flex items-center gap-1.5">
<MapPin className="h-3 w-3 shrink-0" />
<span className="truncate">{plant.zone}</span>
</div>
)}
{plant.plantedAt && (
<div className="flex items-center gap-1.5">
<CalendarDays className="h-3 w-3 shrink-0" />
<span>Planted {formatDate(plant.plantedAt)}</span>
</div>
)}
{plant.logs[0] && (
<div className="flex items-center gap-1.5">
<Leaf className="h-3 w-3 shrink-0 text-[hsl(var(--leaf))]" />
<span>
Last {plant.logs[0].type.toLowerCase()} {formatDate(plant.logs[0].date)}
</span>
</div>
)}
</div>
<div className="pt-1 text-xs text-muted-foreground/60">
{plant._count.logs} log {plant._count.logs === 1 ? "entry" : "entries"}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
<GardenGrid plants={plants} groups={groups} />
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold">Reminders</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{overdue.length > 0
? `${overdue.length} overdue · ${upcoming.length} coming up`
: `${upcoming.length} coming up in the next 60 days`}
</p>
</div>
<AddTaskButton />
</div>
{tasks.length === 0 && (
<div className="text-center py-16 text-muted-foreground">
<CalendarDays className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="font-medium">No reminders yet</p>
<p className="text-sm mt-1">Add your first reminder to start tracking what needs doing.</p>
</div>
)}
{overdue.length > 0 && (
<Section title="Overdue" icon={AlertCircle} iconClass="text-destructive">
{overdue.map((task) => (
<TaskCard key={task.id} task={task} overdue />
))}
</Section>
)}
{upcoming.length > 0 && (
<Section title="Coming up" icon={CalendarDays} iconClass="text-[hsl(var(--leaf))]">
{upcoming.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</Section>
)}
{later.length > 0 && (
<Section title="Later" icon={Clock} iconClass="text-muted-foreground">
{later.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</Section>
)}
</div>
);
}
function Section({ title, icon: Icon, iconClass, children }: {
title: string;
icon: React.ElementType;
iconClass: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 mb-3">
<Icon className={`h-4 w-4 ${iconClass}`} />
<h2 className="font-display text-lg font-semibold">{title}</h2>
</div>
{children}
</div>
);
}
function TaskCard({ task, overdue = false }: {
task: Awaited<ReturnType<typeof db.task.findMany>>[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 (
<Card className={overdue ? "border-destructive/40" : ""}>
<CardContent className="py-3 px-4 flex items-start gap-3">
<Icon className={`h-4 w-4 mt-0.5 shrink-0 ${overdue ? "text-destructive" : "text-muted-foreground"}`} />
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2 flex-wrap">
<p className="font-medium text-sm">{task.title}</p>
<Badge variant="secondary" className="text-xs shrink-0">
{CATEGORY_LABELS[task.category]}
</Badge>
</div>
{task.plant && (
<Link href={`/garden/${task.plant.id}`} className="text-xs text-[hsl(var(--leaf))] hover:underline">
{task.plant.commonName}{task.plant.variety ? ` · ${task.plant.variety}` : ""}
</Link>
)}
{task.notes && (
<p className="text-xs text-muted-foreground mt-0.5">{task.notes}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{describeRecurrence(task.recurrence, task.intervalDays, task.month)}
{task.lastDone && ` · Last done ${formatDate(task.lastDone)}`}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="text-right">
<p className={`text-xs font-medium ${overdue ? "text-destructive" : "text-muted-foreground"}`}>
{overdue
? `${Math.abs(daysUntil)}d overdue`
: daysUntil === 0 ? "Today"
: daysUntil === 1 ? "Tomorrow"
: `In ${daysUntil} days`}
</p>
<p className="text-xs text-muted-foreground">{formatDate(task.nextDue)}</p>
</div>
<CompleteTaskButton taskId={task.id} taskTitle={task.title} />
</div>
</CardContent>
</Card>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string[]>([]);
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<PlantSuggestion[]>([]);
@@ -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", {
// 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({
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,
zone: fields.zone.trim() || undefined,
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() {
<div className="space-y-1.5">
<Label>Category *</Label>
<Select
value={fields.category}
onValueChange={(v) => set("category", v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<Select value={fields.category} onValueChange={(v) => set("category", v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{CATEGORY_ORDER.map((cat) => (
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat]}</SelectItem>
@@ -203,14 +254,76 @@ export function AddPlantButton() {
/>
</div>
{/* Multi-location picker */}
<div className="col-span-2 space-y-1.5">
<Label htmlFor="zone">Location on your property</Label>
<Input
id="zone"
placeholder="Back yard north fence, Front guild, Raised bed #1…"
value={fields.zone}
onChange={(e) => set("zone", e.target.value)}
<Label>
Location(s) on your property
<span className="text-muted-foreground font-normal ml-1">(pick as many as you like)</span>
</Label>
{/* Selected zones as chips */}
{selectedZones.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{selectedZones.map((z) => (
<Badge key={z} variant="secondary" className="gap-1 pr-1">
<MapPin className="h-3 w-3" />
{z}
<button
type="button"
onClick={() => toggleZone(z)}
className="ml-0.5 hover:text-destructive transition-colors"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
{/* Existing zones as checkboxes */}
{allZones.length > 0 && (
<div className="border rounded-md divide-y max-h-36 overflow-y-auto">
{allZones.map((z) => (
<label
key={z}
className="flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-accent transition-colors text-sm"
>
<input
type="checkbox"
checked={selectedZones.includes(z)}
onChange={() => toggleZone(z)}
className="accent-[hsl(var(--leaf))]"
/>
{z}
</label>
))}
</div>
)}
{/* Add new zone */}
{addingZone ? (
<div className="flex gap-2 mt-1">
<Input
placeholder="e.g. Front bed, Back fence guild…"
value={newZoneName}
onChange={(e) => setNewZoneName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewZone(); } }}
autoFocus
/>
<Button type="button" size="sm" onClick={addNewZone}>Add</Button>
<Button type="button" variant="outline" size="sm" onClick={() => { setAddingZone(false); setNewZoneName(""); }}>
Cancel
</Button>
</div>
) : (
<button
type="button"
onClick={() => setAddingZone(true)}
className="text-sm text-[hsl(var(--leaf))] hover:underline mt-1"
>
+ Add new location
</button>
)}
</div>
<div className="space-y-1.5">
@@ -224,13 +337,50 @@ export function AddPlantButton() {
</div>
<div className="space-y-1.5">
<Label htmlFor="source">Where it came from</Label>
<Label>Where did it come from?</Label>
<Select value={fields.source} onValueChange={(v) => set("source", v)}>
<SelectTrigger><SelectValue placeholder="Select one…" /></SelectTrigger>
<SelectContent>
<SelectItem value="We planted it">We planted it</SelectItem>
<SelectItem value="Already here">Already here when we moved in</SelectItem>
<SelectItem value="Gift or trade">Gift or trade</SelectItem>
<SelectItem value="Self-seeded">Self-seeded</SelectItem>
</SelectContent>
</Select>
</div>
<div className="col-span-2 space-y-1.5">
<Label>Group (optional)</Label>
{!creatingGroup ? (
<Select
value={fields.groupId}
onValueChange={(v) => {
if (v === "__new__") { setCreatingGroup(true); set("groupId", ""); }
else { set("groupId", v); }
}}
>
<SelectTrigger><SelectValue placeholder="No group" /></SelectTrigger>
<SelectContent>
<SelectItem value="">No group</SelectItem>
{groups.map((g) => (
<SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>
))}
<SelectItem value="__new__">+ Create new group</SelectItem>
</SelectContent>
</Select>
) : (
<div className="flex gap-2">
<Input
id="source"
placeholder="Jung's Nursery, from seed…"
value={fields.source}
onChange={(e) => set("source", e.target.value)}
placeholder="New group name (e.g. Irises)"
value={fields.newGroupName}
onChange={(e) => set("newGroupName", e.target.value)}
autoFocus
/>
<Button type="button" variant="outline" size="sm" onClick={() => { setCreatingGroup(false); set("newGroupName", ""); }}>
Cancel
</Button>
</div>
)}
</div>
<div className="col-span-2 space-y-1.5">
@@ -248,7 +398,7 @@ export function AddPlantButton() {
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>Cancel</Button>
<Button type="submit" disabled={busy}>
{busy ? "Saving…" : "Add plant"}
{busy ? "Saving…" : selectedZones.length > 1 ? `Add to ${selectedZones.length} locations` : "Add plant"}
</Button>
</DialogFooter>
</form>

View File

@@ -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 }) {
</div>
<div className="col-span-2 space-y-1.5">
<Label htmlFor="edit-zone">Location on your property</Label>
<Input id="edit-zone" {...form.register("zone")} />
<Label>Location on your property</Label>
{!creatingZone ? (
<Select
defaultValue={plant.zone ?? ""}
onValueChange={(v) => {
if (v === "__new__") { setCreatingZone(true); form.setValue("zone", ""); }
else { form.setValue("zone", v); setNewZoneName(""); }
}}
>
<SelectTrigger>
<SelectValue placeholder="No location" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">No location</SelectItem>
{zones.map((z) => (
<SelectItem key={z} value={z}>{z}</SelectItem>
))}
<SelectItem value="__new__">+ Add new location</SelectItem>
</SelectContent>
</Select>
) : (
<div className="flex gap-2">
<Input
placeholder="e.g. Front bed, Back fence guild, Raised bed #1"
value={newZoneName}
onChange={(e) => setNewZoneName(e.target.value)}
autoFocus
/>
<Button type="button" variant="outline" size="sm" onClick={() => { setCreatingZone(false); setNewZoneName(""); }}>
Cancel
</Button>
</div>
)}
</div>
<div className="space-y-1.5">
@@ -133,8 +167,21 @@ export function EditPlantButton({ plant }: { plant: Plant }) {
</div>
<div className="space-y-1.5">
<Label htmlFor="edit-source">Where it came from</Label>
<Input id="edit-source" {...form.register("source")} />
<Label>Where did it come from?</Label>
<Select
defaultValue={plant.source ?? ""}
onValueChange={(v) => form.setValue("source", v)}
>
<SelectTrigger>
<SelectValue placeholder="Select one…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="We planted it">We planted it</SelectItem>
<SelectItem value="Already here">Already here when we moved in</SelectItem>
<SelectItem value="Gift or trade">Gift or trade</SelectItem>
<SelectItem value="Self-seeded">Self-seeded</SelectItem>
</SelectContent>
</Select>
</div>
<div className="col-span-2 space-y-1.5">

View File

@@ -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<string, Plant[]>();
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<Set<string>>(new Set());
const [view, setView] = useState<ViewMode>("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 (
<div className="text-center py-16 text-muted-foreground">
<Leaf className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="font-medium">No plants yet</p>
<p className="text-sm mt-1">Add your first plant to start building your food forest registry.</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-1 p-1 bg-muted rounded-lg w-fit">
<Button size="sm" variant={view === "type" ? "secondary" : "ghost"} className="h-7 text-xs" onClick={() => setView("type")}>
By type
</Button>
<Button size="sm" variant={view === "location" ? "secondary" : "ghost"} className="h-7 text-xs" onClick={() => setView("location")}>
By location
</Button>
</div>
{view === "type" ? (
<TypeView plants={plants} groups={groups} expanded={expanded} toggle={toggle} />
) : (
<LocationView plants={plants} expanded={expanded} toggle={toggle} />
)}
</div>
);
}
function TypeView({ plants, groups, expanded, toggle }: {
plants: Plant[];
groups: Group[];
expanded: Set<string>;
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 (
<div className="space-y-6">
{grouped.map((g) => {
const isOpen = expanded.has(g.id);
const clusters = clusterByVariety(g.plants);
return (
<div key={g.id} className="space-y-2">
<div className="flex items-center gap-2">
<button
onClick={() => toggle(g.id)}
className="flex items-center gap-2 text-left hover:text-foreground transition-colors"
>
{isOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
<Users className="h-4 w-4 text-[hsl(var(--leaf))]" />
<span className="font-display font-semibold">{g.name}</span>
<span className="text-sm text-muted-foreground">· {g.plants.length} {g.plants.length === 1 ? "plant" : "plants"}</span>
</button>
<GroupLogButton groupId={g.id} groupName={g.name} />
</div>
{isOpen ? (
<div className="pl-6 space-y-2">
{clusters.map((cluster) => (
<VarietyRow key={`${cluster[0].commonName}||${cluster[0].variety}`} plants={cluster} expanded={expanded} toggle={toggle} />
))}
</div>
) : (
<button onClick={() => toggle(g.id)} className="pl-6 text-sm text-muted-foreground hover:text-foreground transition-colors">
{clusters.map((c) => c[0].variety ? `${c[0].commonName} (${c[0].variety})` : c[0].commonName).join(", ")}
</button>
)}
</div>
);
})}
{ungrouped.length > 0 && (
<div className="space-y-2">
{grouped.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Other plants</p>}
<div className="space-y-2">
{clusterByVariety(ungrouped).map((cluster) => (
<VarietyRow key={`${cluster[0].commonName}||${cluster[0].variety}`} plants={cluster} expanded={expanded} toggle={toggle} />
))}
</div>
</div>
)}
</div>
);
}
// A single variety that may span multiple locations.
function VarietyRow({ plants, expanded, toggle }: {
plants: Plant[];
expanded: Set<string>;
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 (
<div className="space-y-2">
<button
onClick={() => toggle(key)}
className="flex items-start gap-2 text-left hover:text-foreground transition-colors w-full"
>
{isOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" /> : <ChevronRight className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />}
<div className="flex-1 min-w-0">
<span className="font-medium text-sm">
{rep.commonName}
{rep.variety && <span className="text-muted-foreground font-normal"> · {rep.variety}</span>}
</span>
{!isOpen && (
<div className="flex flex-wrap gap-1 mt-1">
{plants.map((p) => (
<Badge key={p.id} variant="outline" className="text-xs gap-1">
<MapPin className="h-2.5 w-2.5" />
{p.zone ?? "No location"}
</Badge>
))}
</div>
)}
</div>
</button>
{isOpen && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pl-6">
{plants.map((p) => <PlantCard key={p.id} plant={p} />)}
</div>
)}
</div>
);
}
function LocationView({ plants, expanded, toggle }: {
plants: Plant[];
expanded: Set<string>;
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 (
<div className="space-y-6">
{zones.map((zone) => {
const zonePlants = plants.filter((p) => p.zone === zone);
const isOpen = expanded.has(zone);
return (
<div key={zone} className="space-y-2">
<div className="flex items-center gap-2">
<button
onClick={() => toggle(zone)}
className="flex items-center gap-2 text-left hover:text-foreground transition-colors"
>
{isOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
<MapPin className="h-4 w-4 text-[hsl(var(--leaf))]" />
<span className="font-display font-semibold">{zone}</span>
<span className="text-sm text-muted-foreground">· {zonePlants.length} {zonePlants.length === 1 ? "plant" : "plants"}</span>
</button>
<ZoneLogButton zone={zone} plantCount={zonePlants.length} />
</div>
{isOpen ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pl-6">
{zonePlants.map((p) => <PlantCard key={p.id} plant={p} />)}
</div>
) : (
<button onClick={() => toggle(zone)} className="pl-6 text-sm text-muted-foreground hover:text-foreground transition-colors">
{zonePlants.map((p) => p.variety ? `${p.commonName} (${p.variety})` : p.commonName).join(", ")}
</button>
)}
</div>
);
})}
{noZone.length > 0 && (
<div className="space-y-2">
{zones.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location set</p>}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{noZone.map((p) => <PlantCard key={p.id} plant={p} />)}
</div>
</div>
)}
</div>
);
}
function PlantCard({ plant }: { plant: Plant }) {
return (
<Link href={`/garden/${plant.id}`}>
<Card className="hover:shadow-md transition-shadow h-full cursor-pointer overflow-hidden">
{plant.imageUrl && (
<div className="h-36 w-full overflow-hidden bg-muted">
<img
src={plant.imageUrl}
alt={plant.commonName}
className="h-full w-full object-cover"
/>
</div>
)}
{!plant.imageUrl && (
<div className="h-36 w-full bg-[hsl(var(--leaf)/.08)] flex items-center justify-center">
<Leaf className="h-10 w-10 text-[hsl(var(--leaf)/.3)]" />
</div>
)}
<CardContent className="pt-3 pb-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium truncate">
{plant.commonName}
{plant.variety && <span className="text-muted-foreground font-normal"> · {plant.variety}</span>}
</p>
{plant.species && <p className="text-xs text-muted-foreground italic truncate">{plant.species}</p>}
</div>
<Badge variant="secondary" className="shrink-0 text-xs">
{CATEGORY_LABELS[plant.category as keyof typeof CATEGORY_LABELS]}
</Badge>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
{plant.zone && (
<div className="flex items-center gap-1.5">
<MapPin className="h-3 w-3 shrink-0" />
<span className="truncate">{plant.zone}</span>
</div>
)}
{plant.plantedAt && (
<div className="flex items-center gap-1.5">
<CalendarDays className="h-3 w-3 shrink-0" />
<span>Planted {formatDate(plant.plantedAt)}</span>
</div>
)}
{plant.logs[0] && (
<div className="flex items-center gap-1.5">
<Leaf className="h-3 w-3 shrink-0 text-[hsl(var(--leaf))]" />
<span>Last {plant.logs[0].type.toLowerCase()} {formatDate(plant.logs[0].date)}</span>
</div>
)}
</div>
<div className="pt-1 text-xs text-muted-foreground/60">
{plant._count.logs} log {plant._count.logs === 1 ? "entry" : "entries"}
</div>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -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 (
<>
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={(e) => { e.stopPropagation(); setOpen(true); }}
>
<ClipboardList className="h-3.5 w-3.5 mr-1" />
Log all
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Log for all {groupName}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 pt-1">
<div className="space-y-1.5">
<Label>Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOG_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{LOG_TYPE_LABELS[t]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea
rows={3}
placeholder="What did you do?"
value={notes}
onChange={(e) => setNotes(e.target.value)}
autoFocus
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button
type="submit"
disabled={busy}
className="bg-[hsl(var(--leaf))] hover:bg-[hsl(var(--leaf)/.85)]"
>
{busy ? "Saving…" : "Save log"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -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 (
<>
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={(e) => { e.stopPropagation(); setOpen(true); }}
>
<ClipboardList className="h-3.5 w-3.5 mr-1" />
Log all
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Log for all plants in {zone}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 pt-1">
<div className="space-y-1.5">
<Label>Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{LOG_TYPES.map((t) => (
<SelectItem key={t} value={t}>{LOG_TYPE_LABELS[t]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea
rows={3}
placeholder="What did you do?"
value={notes}
onChange={(e) => setNotes(e.target.value)}
autoFocus
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={busy} className="bg-[hsl(var(--leaf))] hover:bg-[hsl(var(--leaf)/.85)]">
{busy ? "Saving…" : "Save log"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

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

View File

@@ -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<string, unknown> = {
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 (
<>
<Button onClick={() => setOpen(true)} className="gap-2 bg-[hsl(var(--leaf))] hover:bg-[hsl(var(--leaf)/.85)]">
<Plus className="h-4 w-4" />
Add reminder
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Bell className="h-4 w-4" />
New reminder
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 pt-1">
<div className="space-y-1.5">
<Label htmlFor="t-title">What needs doing? *</Label>
<Input
id="t-title"
placeholder="e.g. Water new apple tree"
value={fields.title}
onChange={(e) => set("title", e.target.value)}
autoFocus
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="t-category">Category</Label>
<Select value={fields.category} onValueChange={(v) => set("category", v)}>
<SelectTrigger id="t-category">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GARDEN">Garden</SelectItem>
<SelectItem value="HOME">Home</SelectItem>
<SelectItem value="EQUIPMENT">Equipment</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="t-recurrence">Repeats</Label>
<Select value={fields.recurrence} onValueChange={(v) => set("recurrence", v)}>
<SelectTrigger id="t-recurrence">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ONCE">Just once</SelectItem>
<SelectItem value="CUSTOM">Every N days</SelectItem>
<SelectItem value="MONTHLY">Every month</SelectItem>
<SelectItem value="YEARLY">Every year</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{fields.recurrence === "CUSTOM" && (
<div className="space-y-1.5">
<Label htmlFor="t-interval">Every how many days?</Label>
<Input
id="t-interval"
type="number"
min="1"
value={fields.intervalDays}
onChange={(e) => set("intervalDays", e.target.value)}
/>
</div>
)}
{fields.recurrence === "YEARLY" && (
<div className="space-y-1.5">
<Label htmlFor="t-month">Which month?</Label>
<Select value={fields.month} onValueChange={(v) => set("month", v)}>
<SelectTrigger id="t-month">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MONTH_NAMES.map((name, i) => (
<SelectItem key={i + 1} value={String(i + 1)}>{name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="t-nextdue">First due date</Label>
<Input
id="t-nextdue"
type="date"
value={fields.nextDue}
onChange={(e) => set("nextDue", e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="t-notes">Notes (optional)</Label>
<Textarea
id="t-notes"
rows={2}
placeholder="Any extra details..."
value={fields.notes}
onChange={(e) => set("notes", e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={busy}
className="bg-[hsl(var(--leaf))] hover:bg-[hsl(var(--leaf)/.85)]"
>
{busy ? "Saving…" : "Add reminder"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -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 (
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={markDone}
className="shrink-0"
>
<CheckCircle2 className="h-4 w-4 mr-1" />
{busy ? "…" : "Done"}
</Button>
);
}

View File

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

View File

@@ -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,40 +137,105 @@ 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" },
// 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: "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" },
@@ -179,6 +244,8 @@ export const PLANT_DB: PlantSuggestion[] = [
{ 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

View File

@@ -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<string | null> {
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;
}

76
src/lib/tasks/schedule.ts Normal file
View File

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

View File

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