feat: garden plants crud (task 72)

- perenual species lookup with 24h db cache (api key optional)
- /api/garden/species-search route handler
- createPlant / updatePlant / deletePlant server actions
- addPlantImage / removePlantImage / setPrimaryImage actions (max 10)
- listPlants query grouped by container with last-watered subquery
- getPlant query with care log summary and active schedules
- plant-form client component with species autofill and image upload
- plant-list server component grouped by container with unassigned bucket
- plant-detail client component with info/gallery/care tabs
- species-search debounced combobox component
- /garden/plants/new, /[id], /[id]/edit pages
- garden.plant entity registered with search and share support
- /garden page updated to containers/plants tabs (default: containers)
This commit is contained in:
ginnoir
2026-06-01 20:13:20 -05:00
parent a86f5471ce
commit f3e38c576c
14 changed files with 1598 additions and 8 deletions
+213 -1
View File
@@ -1,7 +1,7 @@
import { and, desc, eq, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { gardenContainers, gardenPlants } from "../schema";
import { gardenCareLogs, gardenCareSchedules, gardenContainers, gardenPlants } from "../schema";
export type ContainerDto = {
id: string;
@@ -133,3 +133,215 @@ export async function searchContainers(query: string, householdId: string) {
url: `/garden/containers/${r.id}`,
}));
}
// ─── Plant queries ────────────────────────────────────────────────────────────
export type PlantListItemDto = {
id: string;
containerId: string | null;
containerName: string | null;
name: string;
healthStatus: string;
primaryImageUrl: string | null;
category: string;
lastWateredAt: string | null;
hasOverdueCare: boolean;
};
export type CareLogSummaryDto = {
id: string;
careType: string;
notes: string | null;
performedAt: string;
};
export type CareScheduleSummaryDto = {
id: string;
careType: string;
intervalDays: number;
nextDueAt: string | null;
enabled: boolean;
};
export type PlantDetailDto = {
id: string;
householdId: string;
containerId: string | null;
containerName: string | null;
name: string;
scientificName: string | null;
speciesId: string | null;
category: string;
notes: string | null;
acquisitionDate: string | null;
growthStage: string | null;
healthStatus: string;
sunlight: string | null;
wateringNotes: string | null;
fertilizingNotes: string | null;
primaryImageUrl: string | null;
images: string[];
recentCareLogs: CareLogSummaryDto[];
activeSchedules: CareScheduleSummaryDto[];
createdAt: string;
updatedAt: string;
};
export async function listPlants({ containerId }: { containerId?: string } = {}): Promise<
PlantListItemDto[]
> {
const { household } = await getCurrentSession();
const filter = containerId
? and(eq(gardenPlants.householdId, household.id), eq(gardenPlants.containerId, containerId))
: eq(gardenPlants.householdId, household.id);
const rows = await db
.select({
id: gardenPlants.id,
containerId: gardenPlants.containerId,
containerName: gardenContainers.name,
name: gardenPlants.name,
healthStatus: gardenPlants.healthStatus,
primaryImageUrl: gardenPlants.primaryImageUrl,
category: gardenPlants.category,
lastWateredAt: sql<string | null>`(
select performed_at::text from garden_care_logs
where plant_id = ${gardenPlants.id}
and care_type = 'watering'
order by performed_at desc
limit 1
)`,
hasOverdueCare: sql<boolean>`exists(
select 1 from garden_care_schedules
where plant_id = ${gardenPlants.id}
and enabled = true
and next_due_at < now()
)`,
})
.from(gardenPlants)
.leftJoin(gardenContainers, eq(gardenPlants.containerId, gardenContainers.id))
.where(filter)
.orderBy(gardenContainers.name, gardenPlants.name);
return rows.map((r) => ({
...r,
containerName: r.containerName ?? null,
lastWateredAt: r.lastWateredAt ?? null,
hasOverdueCare: !!r.hasOverdueCare,
}));
}
export async function getPlant(id: string): Promise<PlantDetailDto | null> {
const { household } = await getCurrentSession();
const [row] = await db
.select({
id: gardenPlants.id,
householdId: gardenPlants.householdId,
containerId: gardenPlants.containerId,
containerName: gardenContainers.name,
name: gardenPlants.name,
scientificName: gardenPlants.scientificName,
speciesId: gardenPlants.speciesId,
category: gardenPlants.category,
notes: gardenPlants.notes,
acquisitionDate: gardenPlants.acquisitionDate,
growthStage: gardenPlants.growthStage,
healthStatus: gardenPlants.healthStatus,
sunlight: gardenPlants.sunlight,
wateringNotes: gardenPlants.wateringNotes,
fertilizingNotes: gardenPlants.fertilizingNotes,
primaryImageUrl: gardenPlants.primaryImageUrl,
images: gardenPlants.images,
createdAt: gardenPlants.createdAt,
updatedAt: gardenPlants.updatedAt,
})
.from(gardenPlants)
.leftJoin(gardenContainers, eq(gardenPlants.containerId, gardenContainers.id))
.where(and(eq(gardenPlants.id, id), eq(gardenPlants.householdId, household.id)))
.limit(1);
if (!row) return null;
const careLogs = await db
.select({
id: gardenCareLogs.id,
careType: gardenCareLogs.careType,
notes: gardenCareLogs.notes,
performedAt: gardenCareLogs.performedAt,
})
.from(gardenCareLogs)
.where(eq(gardenCareLogs.plantId, id))
.orderBy(desc(gardenCareLogs.performedAt))
.limit(5);
const schedules = await db
.select({
id: gardenCareSchedules.id,
careType: gardenCareSchedules.careType,
intervalDays: gardenCareSchedules.intervalDays,
nextDueAt: gardenCareSchedules.nextDueAt,
enabled: gardenCareSchedules.enabled,
})
.from(gardenCareSchedules)
.where(and(eq(gardenCareSchedules.plantId, id), eq(gardenCareSchedules.enabled, true)));
return {
id: row.id,
householdId: row.householdId,
containerId: row.containerId,
containerName: row.containerName ?? null,
name: row.name,
scientificName: row.scientificName,
speciesId: row.speciesId,
category: row.category,
notes: row.notes,
acquisitionDate: row.acquisitionDate,
growthStage: row.growthStage,
healthStatus: row.healthStatus,
sunlight: row.sunlight,
wateringNotes: row.wateringNotes,
fertilizingNotes: row.fertilizingNotes,
primaryImageUrl: row.primaryImageUrl,
images: row.images,
recentCareLogs: careLogs.map((l) => ({
id: l.id,
careType: l.careType,
notes: l.notes,
performedAt: l.performedAt.toISOString(),
})),
activeSchedules: schedules.map((s) => ({
id: s.id,
careType: s.careType,
intervalDays: s.intervalDays,
nextDueAt: s.nextDueAt?.toISOString() ?? null,
enabled: s.enabled,
})),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export async function searchPlants(query: string, householdId: string) {
const rows = await db
.select({
id: gardenPlants.id,
name: gardenPlants.name,
scientificName: gardenPlants.scientificName,
})
.from(gardenPlants)
.where(
and(
eq(gardenPlants.householdId, householdId),
sql`(${gardenPlants.name} || ' ' || coalesce(${gardenPlants.scientificName}, '')) ilike ${`%${query}%`}`,
),
)
.limit(10);
return rows.map((r) => ({
id: r.id,
title: r.name + (r.scientificName ? ` (${r.scientificName})` : ""),
url: `/garden/plants/${r.id}`,
}));
}