diff --git a/STATUS.md b/STATUS.md index c82c5f7..c9df268 100644 --- a/STATUS.md +++ b/STATUS.md @@ -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 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) 3. Shared rich-text + notes overhaul: task 85 (+ ADR 0004); closes notes mobile overflow 4. Journal: task 86 (+ ADR 0005), including journal API endpoints diff --git a/src/modules/garden/server/queries.ts b/src/modules/garden/server/queries.ts index 567293b..85cf7bc 100644 --- a/src/modules/garden/server/queries.ts +++ b/src/modules/garden/server/queries.ts @@ -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 { 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`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`count(*)::int`, + }) + .from(gardenPlants) + .where(eq(gardenPlants.householdId, household.id)) + .groupBy(gardenPlants.containerId), + ]); + + const plantCountByContainer = new Map(); + 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`(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`count(*)::int` }) @@ -518,7 +537,7 @@ export async function getGardenOverviewStats(householdId: string): Promise { }); 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(); });