fix: garden container plant count

This commit is contained in:
ginnoir
2026-07-04 18:15:54 -05:00
parent 7eeb2f15bc
commit 700ee29f83
3 changed files with 80 additions and 33 deletions
+4 -1
View File
@@ -4,6 +4,9 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
## Done
- **80 — Quick-add create UI** (commit `68a573c`). Quick-add actions open create dialogs/sheets in-place via `createKey` + `QuickAddCreateHost`; calendar, lists, notes, garden, bangs wired. E2E in `tests/e2e/quick-add.spec.ts`.
- **81 — Dashboard edit live widgets** (commit `7eeb2f1`). Edit mode server-pre-renders real widget content via `DashboardWidgetContent` + `widgetContents` map; no placeholder→content reflow on save. E2E in `tests/e2e/dashboard.spec.ts`.
- **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green.
- **02 — Next.js app skeleton**. Next.js 15 + React 19 + Tailwind v4 + shadcn/ui (button, card, input, dialog). `pnpm dev` serves placeholder, `pnpm build` produces `.next/standalone/`, `pnpm lint` clean. Added `.npmrc` with `node-linker=hoisted` for Windows symlink compatibility.
- **03 — Drizzle + Postgres setup**. drizzle-orm + postgres driver + drizzle-kit wired up. `src/modules/_core/schema.ts` declares `users`, `households`, `household_members`. `docker-compose.dev.yaml` starts Postgres 16. `drizzle/0000_silent_magma.sql` generated and applied. `tsc --noEmit` passes.
@@ -46,7 +49,7 @@ Phase 9 — Post-v0.1 (see `docs/superpowers/specs/2026-07-03-backlog-triage-des
**Batch order:**
1. Bugs: tasks 8084 (quick-add, dashboard edit, garden count, bangs, back-nav)
1. Bugs: tasks 8284 (garden count, bangs, back-nav) — 8081 done
2. API foundation: task 87 (+ ADR 0006)
3. Shared rich-text + notes overhaul: task 85 (+ ADR 0004); closes notes mobile overflow
4. Journal: task 86 (+ ADR 0005), including journal API endpoints
+43 -24
View File
@@ -5,6 +5,12 @@ import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { gardenCareLogs, gardenCareSchedules, gardenContainers, gardenPlants } from "../schema";
function toCount(value: number | string | bigint | null | undefined): number {
if (value == null) return 0;
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
export type ContainerDto = {
id: string;
householdId: string;
@@ -34,29 +40,43 @@ export type PlantSummaryDto = {
export async function listContainers(): Promise<ContainerDto[]> {
const { household } = await getCurrentSession();
const rows = await db
.select({
id: gardenContainers.id,
householdId: gardenContainers.householdId,
name: gardenContainers.name,
type: gardenContainers.type,
locationNotes: gardenContainers.locationNotes,
coverImageUrl: gardenContainers.coverImageUrl,
images: gardenContainers.images,
createdAt: gardenContainers.createdAt,
updatedAt: gardenContainers.updatedAt,
plantCount: sql<number>`count(${gardenPlants.id})::int`,
})
.from(gardenContainers)
.leftJoin(gardenPlants, eq(gardenPlants.containerId, gardenContainers.id))
.where(eq(gardenContainers.householdId, household.id))
.groupBy(gardenContainers.id)
.orderBy(gardenContainers.name);
const [rows, countRows] = await Promise.all([
db
.select({
id: gardenContainers.id,
householdId: gardenContainers.householdId,
name: gardenContainers.name,
type: gardenContainers.type,
locationNotes: gardenContainers.locationNotes,
coverImageUrl: gardenContainers.coverImageUrl,
images: gardenContainers.images,
createdAt: gardenContainers.createdAt,
updatedAt: gardenContainers.updatedAt,
})
.from(gardenContainers)
.where(eq(gardenContainers.householdId, household.id))
.orderBy(gardenContainers.name),
db
.select({
containerId: gardenPlants.containerId,
plantCount: sql<number>`count(*)::int`,
})
.from(gardenPlants)
.where(eq(gardenPlants.householdId, household.id))
.groupBy(gardenPlants.containerId),
]);
const plantCountByContainer = new Map<string, number>();
for (const row of countRows) {
if (row.containerId) {
plantCountByContainer.set(row.containerId, toCount(row.plantCount));
}
}
return rows.map((r) => ({
...r,
images: r.images ?? [],
plantCount: r.plantCount ?? 0,
plantCount: plantCountByContainer.get(r.id) ?? 0,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
}));
@@ -76,7 +96,6 @@ export async function getContainer(id: string): Promise<ContainerDetailDto | nul
images: gardenContainers.images,
createdAt: gardenContainers.createdAt,
updatedAt: gardenContainers.updatedAt,
plantCount: sql<number>`(select count(*)::int from garden_plants where container_id = ${gardenContainers.id})`,
})
.from(gardenContainers)
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, household.id)))
@@ -105,7 +124,7 @@ export async function getContainer(id: string): Promise<ContainerDetailDto | nul
locationNotes: container.locationNotes,
coverImageUrl: container.coverImageUrl,
images: container.images ?? [],
plantCount: container.plantCount ?? 0,
plantCount: plants.length,
createdAt: container.createdAt.toISOString(),
updatedAt: container.updatedAt.toISOString(),
plants,
@@ -505,8 +524,8 @@ export async function getGardenOverviewStats(householdId: string): Promise<Garde
.where(eq(gardenContainers.householdId, householdId)),
]);
const plantCount = plantRow[0]?.count ?? 0;
const containerCount = containerRow[0]?.count ?? 0;
const plantCount = toCount(plantRow[0]?.count);
const containerCount = toCount(containerRow[0]?.count);
const overdueRows = await db
.select({ count: sql<number>`count(*)::int` })
@@ -518,7 +537,7 @@ export async function getGardenOverviewStats(householdId: string): Promise<Garde
lte(gardenCareSchedules.nextDueAt, sql`now()`),
),
);
const overdueCount = overdueRows[0]?.count ?? 0;
const overdueCount = toCount(overdueRows[0]?.count);
const nextRows = await db
.select({
+33 -8
View File
@@ -138,31 +138,56 @@ test("garden integrations - push overdue to task list", async ({ page }) => {
});
test("garden containers happy path", async ({ page }) => {
const suffix = Date.now().toString();
const containerName = `Living Room Shelf ${suffix}`;
const plantName = `Shelf Plant ${suffix}`;
await page.goto("/garden");
await expect(page.getByRole("heading", { name: "Garden" })).toBeVisible();
// Create a container
await page.getByRole("button", { name: /new container/i }).click();
await page.getByLabel("Name").fill("Living Room Shelf");
await page.getByLabel("Name").fill(containerName);
await page.getByLabel("Type").selectOption("shelf");
await page.getByRole("button", { name: /save|create/i }).click();
// Verify it appears in the list
await expect(page.getByText("Living Room Shelf")).toBeVisible();
// Verify it appears in the list with zero plants
await expect(page.getByText(containerName)).toBeVisible();
const containerCard = page.locator("a.card", { hasText: containerName });
await expect(containerCard.getByText("0 plants")).toBeVisible();
// Open the container detail
await page.getByText("Living Room Shelf").click();
await expect(page.getByRole("heading", { name: "Living Room Shelf" })).toBeVisible();
await page.getByText(containerName).click();
await expect(page.getByRole("heading", { name: containerName })).toBeVisible();
// Add a plant assigned to this container
await page.getByRole("link", { name: /add plant/i }).click();
await expect(page).toHaveURL(/containerId=/);
await page.getByLabel("Name").fill(plantName);
await page.getByRole("button", { name: /create/i }).click();
await expect(page).toHaveURL(/\/garden\/plants\//);
// Container detail should show the plant count
await page.goto("/garden");
await page.getByText(containerName).click();
await expect(page.getByRole("heading", { name: new RegExp(`Plants \\(1\\)`) })).toBeVisible();
await expect(page.getByText(plantName)).toBeVisible();
// Container list should show the updated count
await page.goto("/garden");
await expect(containerCard.getByText("1 plant")).toBeVisible();
// Edit the container
await page.getByText(containerName).click();
await page.getByRole("button", { name: /edit/i }).click();
await page.getByLabel("Name").fill("Living Room Shelf (Updated)");
const updatedName = `${containerName} (Updated)`;
await page.getByLabel("Name").fill(updatedName);
await page.getByRole("button", { name: /save/i }).click();
await expect(page.getByRole("heading", { name: "Living Room Shelf (Updated)" })).toBeVisible();
await expect(page.getByRole("heading", { name: updatedName })).toBeVisible();
// Delete the container
await page.getByRole("button", { name: /delete/i }).click();
await page.getByRole("button", { name: /confirm|yes/i }).click();
await expect(page).toHaveURL(/\/garden/);
await expect(page.getByText("Living Room Shelf")).toBeHidden();
await expect(page.getByText(updatedName)).toBeHidden();
});