fix: garden container plant count
This commit is contained in:
@@ -4,6 +4,9 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
|
|||||||
|
|
||||||
## Done
|
## 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.
|
- **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.
|
- **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.
|
- **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:**
|
**Batch order:**
|
||||||
|
|
||||||
1. Bugs: tasks 80–84 (quick-add, dashboard edit, garden count, bangs, back-nav)
|
1. Bugs: tasks 82–84 (garden count, bangs, back-nav) — 80–81 done
|
||||||
2. API foundation: task 87 (+ ADR 0006)
|
2. API foundation: task 87 (+ ADR 0006)
|
||||||
3. Shared rich-text + notes overhaul: task 85 (+ ADR 0004); closes notes mobile overflow
|
3. Shared rich-text + notes overhaul: task 85 (+ ADR 0004); closes notes mobile overflow
|
||||||
4. Journal: task 86 (+ ADR 0005), including journal API endpoints
|
4. Journal: task 86 (+ ADR 0005), including journal API endpoints
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import { db } from "@/lib/db";
|
|||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { gardenCareLogs, gardenCareSchedules, gardenContainers, gardenPlants } from "../schema";
|
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 = {
|
export type ContainerDto = {
|
||||||
id: string;
|
id: string;
|
||||||
householdId: string;
|
householdId: string;
|
||||||
@@ -34,29 +40,43 @@ export type PlantSummaryDto = {
|
|||||||
export async function listContainers(): Promise<ContainerDto[]> {
|
export async function listContainers(): Promise<ContainerDto[]> {
|
||||||
const { household } = await getCurrentSession();
|
const { household } = await getCurrentSession();
|
||||||
|
|
||||||
const rows = await db
|
const [rows, countRows] = await Promise.all([
|
||||||
.select({
|
db
|
||||||
id: gardenContainers.id,
|
.select({
|
||||||
householdId: gardenContainers.householdId,
|
id: gardenContainers.id,
|
||||||
name: gardenContainers.name,
|
householdId: gardenContainers.householdId,
|
||||||
type: gardenContainers.type,
|
name: gardenContainers.name,
|
||||||
locationNotes: gardenContainers.locationNotes,
|
type: gardenContainers.type,
|
||||||
coverImageUrl: gardenContainers.coverImageUrl,
|
locationNotes: gardenContainers.locationNotes,
|
||||||
images: gardenContainers.images,
|
coverImageUrl: gardenContainers.coverImageUrl,
|
||||||
createdAt: gardenContainers.createdAt,
|
images: gardenContainers.images,
|
||||||
updatedAt: gardenContainers.updatedAt,
|
createdAt: gardenContainers.createdAt,
|
||||||
plantCount: sql<number>`count(${gardenPlants.id})::int`,
|
updatedAt: gardenContainers.updatedAt,
|
||||||
})
|
})
|
||||||
.from(gardenContainers)
|
.from(gardenContainers)
|
||||||
.leftJoin(gardenPlants, eq(gardenPlants.containerId, gardenContainers.id))
|
.where(eq(gardenContainers.householdId, household.id))
|
||||||
.where(eq(gardenContainers.householdId, household.id))
|
.orderBy(gardenContainers.name),
|
||||||
.groupBy(gardenContainers.id)
|
db
|
||||||
.orderBy(gardenContainers.name);
|
.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) => ({
|
return rows.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
images: r.images ?? [],
|
images: r.images ?? [],
|
||||||
plantCount: r.plantCount ?? 0,
|
plantCount: plantCountByContainer.get(r.id) ?? 0,
|
||||||
createdAt: r.createdAt.toISOString(),
|
createdAt: r.createdAt.toISOString(),
|
||||||
updatedAt: r.updatedAt.toISOString(),
|
updatedAt: r.updatedAt.toISOString(),
|
||||||
}));
|
}));
|
||||||
@@ -76,7 +96,6 @@ export async function getContainer(id: string): Promise<ContainerDetailDto | nul
|
|||||||
images: gardenContainers.images,
|
images: gardenContainers.images,
|
||||||
createdAt: gardenContainers.createdAt,
|
createdAt: gardenContainers.createdAt,
|
||||||
updatedAt: gardenContainers.updatedAt,
|
updatedAt: gardenContainers.updatedAt,
|
||||||
plantCount: sql<number>`(select count(*)::int from garden_plants where container_id = ${gardenContainers.id})`,
|
|
||||||
})
|
})
|
||||||
.from(gardenContainers)
|
.from(gardenContainers)
|
||||||
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, household.id)))
|
.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,
|
locationNotes: container.locationNotes,
|
||||||
coverImageUrl: container.coverImageUrl,
|
coverImageUrl: container.coverImageUrl,
|
||||||
images: container.images ?? [],
|
images: container.images ?? [],
|
||||||
plantCount: container.plantCount ?? 0,
|
plantCount: plants.length,
|
||||||
createdAt: container.createdAt.toISOString(),
|
createdAt: container.createdAt.toISOString(),
|
||||||
updatedAt: container.updatedAt.toISOString(),
|
updatedAt: container.updatedAt.toISOString(),
|
||||||
plants,
|
plants,
|
||||||
@@ -505,8 +524,8 @@ export async function getGardenOverviewStats(householdId: string): Promise<Garde
|
|||||||
.where(eq(gardenContainers.householdId, householdId)),
|
.where(eq(gardenContainers.householdId, householdId)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const plantCount = plantRow[0]?.count ?? 0;
|
const plantCount = toCount(plantRow[0]?.count);
|
||||||
const containerCount = containerRow[0]?.count ?? 0;
|
const containerCount = toCount(containerRow[0]?.count);
|
||||||
|
|
||||||
const overdueRows = await db
|
const overdueRows = await db
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
@@ -518,7 +537,7 @@ export async function getGardenOverviewStats(householdId: string): Promise<Garde
|
|||||||
lte(gardenCareSchedules.nextDueAt, sql`now()`),
|
lte(gardenCareSchedules.nextDueAt, sql`now()`),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const overdueCount = overdueRows[0]?.count ?? 0;
|
const overdueCount = toCount(overdueRows[0]?.count);
|
||||||
|
|
||||||
const nextRows = await db
|
const nextRows = await db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -138,31 +138,56 @@ test("garden integrations - push overdue to task list", async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("garden containers happy path", 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 page.goto("/garden");
|
||||||
await expect(page.getByRole("heading", { name: "Garden" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Garden" })).toBeVisible();
|
||||||
|
|
||||||
// Create a container
|
// Create a container
|
||||||
await page.getByRole("button", { name: /new container/i }).click();
|
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.getByLabel("Type").selectOption("shelf");
|
||||||
await page.getByRole("button", { name: /save|create/i }).click();
|
await page.getByRole("button", { name: /save|create/i }).click();
|
||||||
|
|
||||||
// Verify it appears in the list
|
// Verify it appears in the list with zero plants
|
||||||
await expect(page.getByText("Living Room Shelf")).toBeVisible();
|
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
|
// Open the container detail
|
||||||
await page.getByText("Living Room Shelf").click();
|
await page.getByText(containerName).click();
|
||||||
await expect(page.getByRole("heading", { name: "Living Room Shelf" })).toBeVisible();
|
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
|
// Edit the container
|
||||||
|
await page.getByText(containerName).click();
|
||||||
await page.getByRole("button", { name: /edit/i }).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 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
|
// Delete the container
|
||||||
await page.getByRole("button", { name: /delete/i }).click();
|
await page.getByRole("button", { name: /delete/i }).click();
|
||||||
await page.getByRole("button", { name: /confirm|yes/i }).click();
|
await page.getByRole("button", { name: /confirm|yes/i }).click();
|
||||||
await expect(page).toHaveURL(/\/garden/);
|
await expect(page).toHaveURL(/\/garden/);
|
||||||
await expect(page.getByText("Living Room Shelf")).toBeHidden();
|
await expect(page.getByText(updatedName)).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user