v0.23.0 — Pantry: structured USED pull logs + move to /api/v1 service layer

Pantry now runs on the v1 API like Inventory/Animals: service layer in
src/lib/services/pantry.ts, pure logic + tests in src/lib/pantry/core.ts,
thin routes at /api/v1/pantry/* riding the items:* scopes. Pulls write
ItemLog type USED with a structured amount (queryable consumption).
Bulk-use runs its reads inside the transaction, accumulates repeated
pulls, and reports skipped items instead of silently dropping them —
the Pull dialog now surfaces those by name. Old /api/pantry routes
deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bonna Moon
2026-07-06 00:14:53 -05:00
parent 8f77b7c453
commit 25b1ba9aeb
27 changed files with 723 additions and 453 deletions

View File

@@ -16,8 +16,13 @@ user-facing overview.
`serial`/`modelNumber`/`brand`, a **sold** lifecycle, and freeform `customFields`
(JSON, e.g. VIN). Maintenance = a `Task` linked via `Task.itemId`. **HomeBox CSV
import** at `/api/v1/items/import` (pure parser `src/lib/inventory/homebox-import.ts`).
`kind` is DURABLE|CONSUMABLE — consumable/Pantry is unbuilt (fields present so it
needs no migration). Shares the Location spine.
`kind` is DURABLE|CONSUMABLE. Shares the Location spine.
- **Pantry** (Kitchen → Pantry & Freezer) — CONSUMABLE Items with categories,
price paid, tags (shared `Label` table), and expiry. Pulls go through
`/api/v1/pantry/[id]/use` or `/bulk-use` and write `ItemLog` type `USED` with a
structured `amount` (consumption is queryable — don't regress to NOTE strings).
Service `src/lib/services/pantry.ts`; pure logic + tests in `src/lib/pantry/core.ts`.
Rides the `items:*` scopes (pantry items ARE Items).
- **Animals** — `Animal` + `AnimalLog` (pets/livestock); placed on the Location
tree, and `Task`s can link to an animal (`Task.animalId`).
- **Locations** — `Location` tree (self-ref `parentId`) shared by plants, items, animals.
@@ -100,6 +105,9 @@ reloads. **Never rsync over the appdata dir** — it fights the checkout.
- **Every commit bumps APP_VERSION** in `src/lib/version.ts` + a plain-English
entry atop `src/lib/changelog.ts` (the footer shows it + an "update available"
badge from `src/lib/update-check.ts`). Minor for features/migrations, patch for fixes.
**Fetch first**: parallel sessions push to master — check
`git show origin/master:src/lib/version.ts` before picking a number
(a v0.22.0 collision has already happened once).
- **AuditEvent on every mutation** — append-only.
- **Soft-delete** via `active=false`.
- **Pure core + colocated test** — logic in `src/lib/...` with a `.test.ts`; routes thin.

View File

@@ -0,0 +1,5 @@
-- AlterEnum
ALTER TYPE "ItemLogType" ADD VALUE 'USED';
-- AlterTable
ALTER TABLE "ItemLog" ADD COLUMN "amount" DECIMAL(10,2);

View File

@@ -420,6 +420,7 @@ enum ItemLogType {
PURCHASED
REPAIRED
DISPOSED
USED // consumable pulled from the pantry; `amount` holds how much
}
model ItemLog {
@@ -430,6 +431,7 @@ model ItemLog {
date DateTime @default(now())
notes String?
cost Decimal? @db.Decimal(10, 2)
amount Decimal? @db.Decimal(10, 2) // quantity consumed, for USED logs (same unit as the item)
actorId String?
createdAt DateTime @default(now())

View File

@@ -1,23 +1,15 @@
import { db } from "@/lib/db";
import { sessionContext } from "@/lib/api/authenticate";
import { listPantry } from "@/lib/services/pantry";
import { PantryClient } from "@/components/pantry/pantry-client";
export const metadata = { title: "Pantry & Freezer" };
export const dynamic = "force-dynamic";
export default async function PantryPage() {
const ctx = await sessionContext();
const [items, locations, plants] = await Promise.all([
db.item.findMany({
where: { kind: "CONSUMABLE", active: true },
orderBy: [{ location: { name: "asc" } }, { name: "asc" }],
select: {
id: true, name: true, description: true, quantity: true, unit: true,
storedAt: true, bestBefore: true, source: true, plantId: true, notes: true,
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
location: { select: { id: true, name: true } },
plant: { select: { id: true, commonName: true, variety: true } },
labels: { select: { label: { select: { name: true } } } },
},
}),
listPantry(ctx),
db.location.findMany({
where: {
active: true,
@@ -32,27 +24,5 @@ export default async function PantryPage() {
db.plant.findMany({ where: { active: true }, orderBy: { commonName: "asc" }, select: { id: true, commonName: true, variety: true } }),
]);
const serialized = items.map(i => ({
id: i.id,
name: i.name,
description: i.description,
quantity: Number(i.quantity),
unit: i.unit,
packageSize: i.description,
storedAt: i.storedAt,
bestBefore: i.bestBefore,
source: i.source,
notes: i.notes,
pantryCategory: i.pantryCategory,
pantrySubcategory: i.pantrySubcategory,
pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
priceUnit: i.priceUnit,
locationId: i.location?.id ?? null,
locationName: i.location?.name ?? null,
plantId: i.plant?.id ?? null,
plantName: i.plant ? `${i.plant.commonName}${i.plant.variety ? ` (${i.plant.variety})` : ""}` : null,
tags: i.labels.map((l: { label: { name: string } }) => l.label.name),
}));
return <PantryClient initialItems={serialized} locations={locations} plants={plants} />;
return <PantryClient initialItems={items} locations={locations} plants={plants} />;
}

View File

@@ -1,109 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { ItemSource } from "@prisma/client";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
const PANTRY_SELECT = {
id: true, name: true, description: true, quantity: true, unit: true,
storedAt: true, bestBefore: true, source: true, plantId: true, notes: true,
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
location: { select: { id: true, name: true } },
plant: { select: { id: true, commonName: true, variety: true } },
labels: { select: { label: { select: { id: true, name: true } } } },
} as const;
async function syncTags(itemId: string, tags: string[]) {
const names = Array.from(new Set(tags.map(t => t.trim()).filter(Boolean)));
const labels = await Promise.all(
names.map(name => db.label.upsert({ where: { name }, update: {}, create: { name }, select: { id: true } }))
);
await db.labelOnItem.deleteMany({ where: { itemId } });
if (labels.length) {
await db.labelOnItem.createMany({
data: labels.map(l => ({ itemId, labelId: l.id })),
skipDuplicates: true,
});
}
}
const updateSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().nullable().optional(),
quantity: z.number().min(0).optional(),
unit: z.string().nullable().optional(),
packageSize: z.string().nullable().optional(),
locationId: z.string().nullable().optional(),
storedAt: z.string().nullable().optional(),
bestBefore: z.string().nullable().optional(),
source: z.nativeEnum(ItemSource).nullable().optional(),
plantId: z.string().nullable().optional(),
pantryCategory: z.string().nullable().optional(),
pantrySubcategory: z.string().nullable().optional(),
pricePaid: z.number().nullable().optional(),
priceUnit: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
tags: z.array(z.string()).optional(),
});
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
const data = updateSchema.parse(await req.json());
await db.item.update({
where: { id: params.id },
data: {
...(data.name !== undefined && { name: data.name }),
...(data.description !== undefined && { description: data.description ?? data.packageSize ?? null }),
...(data.quantity !== undefined && { quantity: data.quantity }),
...(data.unit !== undefined && { unit: data.unit ?? null }),
...(data.locationId !== undefined && { locationId: data.locationId ?? null }),
...(data.storedAt !== undefined && { storedAt: data.storedAt ? new Date(data.storedAt) : null }),
...(data.bestBefore !== undefined && { bestBefore: data.bestBefore ? new Date(data.bestBefore) : null }),
...(data.source !== undefined && { source: data.source ?? null }),
...(data.plantId !== undefined && { plantId: data.plantId ?? null }),
...(data.pantryCategory !== undefined && { pantryCategory: data.pantryCategory ?? null }),
...(data.pantrySubcategory !== undefined && { pantrySubcategory: data.pantrySubcategory ?? null }),
...(data.pricePaid !== undefined && { pricePaid: data.pricePaid ?? null }),
...(data.priceUnit !== undefined && { priceUnit: data.priceUnit ?? null }),
...(data.notes !== undefined && { notes: data.notes ?? null }),
},
});
if (data.tags !== undefined) await syncTags(params.id, data.tags);
const item = await db.item.findUniqueOrThrow({ where: { id: params.id }, select: PANTRY_SELECT });
await db.auditEvent.create({
data: { action: "pantry.update", entityId: item.id, actorId: session.user.id, payload: { name: item.name } },
});
return NextResponse.json({
...item,
quantity: Number(item.quantity),
pricePaid: item.pricePaid != null ? Number(item.pricePaid) : null,
tags: item.labels.map(l => l.label.name),
});
} catch (err) {
if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { 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 });
}
}
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
await db.item.update({ where: { id: params.id }, data: { active: false } });
await db.auditEvent.create({
data: { action: "pantry.delete", entityId: params.id, actorId: session.user.id },
});
return NextResponse.json({ ok: true });
} catch (err) {
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

@@ -1,48 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
const schema = z.object({
amount: z.number().positive(),
notes: z.string().nullable().optional(),
});
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
const { amount, notes } = schema.parse(await req.json());
const item = await db.item.findUnique({ where: { id: params.id } });
if (!item || !item.active) return NextResponse.json({ error: "Not found" }, { status: 404 });
const newQty = Math.max(0, Number(item.quantity) - amount);
await db.item.update({ where: { id: params.id }, data: { quantity: newQty } });
await db.itemLog.create({
data: {
itemId: params.id,
type: "NOTE",
notes: `Used ${amount} ${item.unit ?? ""}${notes ? `${notes}` : ""}`.trim(),
actorId: session.user.id,
},
});
await db.auditEvent.create({
data: {
action: "pantry.use",
entityId: params.id,
actorId: session.user.id,
payload: { amount, remaining: newQty, notes },
},
});
return NextResponse.json({ ok: true, remaining: newQty });
} catch (err) {
if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { 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

@@ -1,61 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
const schema = z.object({
pulls: z.array(z.object({
id: z.string(),
amount: z.number().positive(),
})).min(1),
notes: z.string().nullable().optional(),
});
export async function POST(req: Request) {
try {
const session = await requireAuth();
const { pulls, notes } = schema.parse(await req.json());
const ids = pulls.map(p => p.id);
const items = await db.item.findMany({
where: { id: { in: ids }, kind: "CONSUMABLE", active: true },
select: { id: true, name: true, quantity: true, unit: true },
});
const itemMap = new Map(items.map(i => [i.id, i]));
const results: { id: string; remaining: number }[] = [];
await db.$transaction(async tx => {
for (const pull of pulls) {
const item = itemMap.get(pull.id);
if (!item) continue;
const remaining = Math.max(0, Number(item.quantity) - pull.amount);
await tx.item.update({ where: { id: pull.id }, data: { quantity: remaining } });
await tx.itemLog.create({
data: {
itemId: pull.id,
type: "NOTE",
notes: `Used ${pull.amount} ${item.unit ?? ""}${notes ? `${notes}` : ""}`.trim(),
actorId: session.user.id,
},
});
results.push({ id: pull.id, remaining });
}
await tx.auditEvent.create({
data: {
action: "pantry.bulk-use",
actorId: session.user.id,
payload: { pulls: pulls.map(p => ({ id: p.id, amount: p.amount })), notes },
},
});
});
return NextResponse.json({ ok: true, results });
} catch (err) {
if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { 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

@@ -1,68 +0,0 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
// Returns distinct pantry item names + their most-recent settings for autocomplete.
export async function GET(req: Request) {
await requireAuth();
const { searchParams } = new URL(req.url);
const q = searchParams.get("q")?.trim().toLowerCase() ?? "";
// Grab all consumable items (active + inactive so we remember old entries).
const items = await db.item.findMany({
where: {
kind: "CONSUMABLE",
...(q ? { name: { contains: q, mode: "insensitive" } } : {}),
},
orderBy: { updatedAt: "desc" },
select: {
name: true, description: true, unit: true, source: true,
pantryCategory: true, pantrySubcategory: true,
priceUnit: true, pricePaid: true,
locationId: true, plantId: true,
location: { select: { id: true, name: true } },
},
take: 200,
});
// Deduplicate by name (case-insensitive), keep the most-recent record per name.
const seen = new Map<string, typeof items[number]>();
for (const item of items) {
const key = item.name.toLowerCase();
if (!seen.has(key)) seen.set(key, item);
}
// Distinct categories/subcategories and all label names for autocomplete.
const [allCats, allLabels] = await Promise.all([
db.item.findMany({
where: { kind: "CONSUMABLE", pantryCategory: { not: null } },
select: { pantryCategory: true, pantrySubcategory: true },
distinct: ["pantryCategory", "pantrySubcategory"],
}),
db.label.findMany({ select: { name: true }, orderBy: { name: "asc" } }),
]);
const categories = Array.from(new Set(allCats.map(c => c.pantryCategory).filter(Boolean))) as string[];
const subcategories = allCats
.filter(c => c.pantrySubcategory)
.map(c => ({ category: c.pantryCategory!, subcategory: c.pantrySubcategory! }));
return NextResponse.json({
suggestions: Array.from(seen.values()).map(i => ({
name: i.name,
description: i.description,
unit: i.unit,
source: i.source,
pantryCategory: i.pantryCategory,
pantrySubcategory: i.pantrySubcategory,
priceUnit: i.priceUnit,
pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
locationId: i.locationId,
locationName: i.location?.name ?? null,
plantId: i.plantId,
})),
categories,
subcategories,
labels: allLabels.map(l => l.name),
});
}

View File

@@ -1,120 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { ItemSource } from "@prisma/client";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
const PANTRY_SELECT = {
id: true, name: true, description: true, quantity: true, unit: true,
storedAt: true, bestBefore: true, source: true, plantId: true, notes: true,
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
location: { select: { id: true, name: true } },
plant: { select: { id: true, commonName: true, variety: true } },
labels: { select: { label: { select: { id: true, name: true } } } },
} as const;
function serialize(i: any) {
return {
id: i.id, name: i.name, description: i.description,
quantity: Number(i.quantity), unit: i.unit,
packageSize: i.description,
storedAt: i.storedAt, bestBefore: i.bestBefore,
source: i.source, notes: i.notes,
pantryCategory: i.pantryCategory,
pantrySubcategory: i.pantrySubcategory,
pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
priceUnit: i.priceUnit,
locationId: i.location?.id ?? null,
locationName: i.location?.name ?? null,
plantId: i.plant?.id ?? null,
plantName: i.plant ? `${i.plant.commonName}${i.plant.variety ? ` (${i.plant.variety})` : ""}` : null,
tags: (i.labels ?? []).map((l: any) => l.label.name) as string[],
};
}
async function syncTags(itemId: string, tags: string[]) {
const names = Array.from(new Set(tags.map(t => t.trim()).filter(Boolean)));
// Upsert each label, then replace all LabelOnItem rows for this item.
const labels = await Promise.all(
names.map(name => db.label.upsert({ where: { name }, update: {}, create: { name }, select: { id: true } }))
);
await db.labelOnItem.deleteMany({ where: { itemId } });
if (labels.length) {
await db.labelOnItem.createMany({
data: labels.map(l => ({ itemId, labelId: l.id })),
skipDuplicates: true,
});
}
}
export async function GET() {
await requireAuth();
const items = await db.item.findMany({
where: { kind: "CONSUMABLE", active: true },
orderBy: [{ location: { name: "asc" } }, { name: "asc" }],
select: PANTRY_SELECT,
});
return NextResponse.json(items.map(serialize));
}
const createSchema = z.object({
name: z.string().min(1),
description: z.string().nullable().optional(),
quantity: z.number().min(0).default(1),
unit: z.string().nullable().optional(),
packageSize: z.string().nullable().optional(),
locationId: z.string().nullable().optional(),
storedAt: z.string().nullable().optional(),
bestBefore: z.string().nullable().optional(),
source: z.nativeEnum(ItemSource).nullable().optional(),
plantId: z.string().nullable().optional(),
pantryCategory: z.string().nullable().optional(),
pantrySubcategory: z.string().nullable().optional(),
pricePaid: z.number().nullable().optional(),
priceUnit: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
tags: z.array(z.string()).optional(),
});
export async function POST(req: Request) {
try {
const session = await requireAuth();
const data = createSchema.parse(await req.json());
const item = await db.item.create({
data: {
name: data.name,
description: data.description ?? data.packageSize ?? null,
kind: "CONSUMABLE",
quantity: data.quantity,
unit: data.unit ?? null,
locationId: data.locationId ?? null,
storedAt: data.storedAt ? new Date(data.storedAt) : null,
bestBefore: data.bestBefore ? new Date(data.bestBefore) : null,
source: data.source ?? null,
plantId: data.plantId ?? null,
pantryCategory: data.pantryCategory ?? null,
pantrySubcategory: data.pantrySubcategory ?? null,
pricePaid: data.pricePaid ?? null,
priceUnit: data.priceUnit ?? null,
notes: data.notes ?? null,
},
select: PANTRY_SELECT,
});
if (data.tags?.length) await syncTags(item.id, data.tags);
await db.auditEvent.create({
data: { action: "pantry.create", entityId: item.id, actorId: session.user.id, payload: { name: item.name } },
});
// Re-fetch with labels
const full = await db.item.findUniqueOrThrow({ where: { id: item.id }, select: PANTRY_SELECT });
return NextResponse.json(serialize(full), { status: 201 });
} catch (err) {
if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { 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,26 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { pantryUpdateInput } from "@/lib/api/schemas";
import { updatePantryItem, deletePantryItem } from "@/lib/services/pantry";
// PATCH /api/v1/pantry/:id
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "items:write");
const input = pantryUpdateInput.parse(await req.json());
return NextResponse.json(await updatePantryItem(ctx, params.id, input));
} catch (err) {
return handleApiError(err);
}
}
// DELETE /api/v1/pantry/:id — soft-delete (active=false)
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "items:write");
await deletePantryItem(ctx, params.id);
return NextResponse.json({ ok: true });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { pantryUseInput } from "@/lib/api/schemas";
import { usePantryItem } from "@/lib/services/pantry";
// POST /api/v1/pantry/:id/use — pull an amount; logs a USED ItemLog
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "items:write");
const input = pantryUseInput.parse(await req.json());
const result = await usePantryItem(ctx, params.id, input);
return NextResponse.json({ ok: true, ...result });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { pantryBulkUseInput } from "@/lib/api/schemas";
import { bulkUsePantry } from "@/lib/services/pantry";
// POST /api/v1/pantry/bulk-use — pull several items at once.
// Responds with `skipped` for any pull whose item no longer exists.
export async function POST(req: Request) {
try {
const ctx = await authenticateRequest(req, "items:write");
const input = pantryBulkUseInput.parse(await req.json());
const result = await bulkUsePantry(ctx, input);
return NextResponse.json({ ok: result.skipped.length === 0, ...result });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { pantryCatalog } from "@/lib/services/pantry";
// GET /api/v1/pantry/catalog?q= — autocomplete data for the add/edit dialog
export async function GET(req: Request) {
try {
const ctx = await authenticateRequest(req, "items:read");
const q = new URL(req.url).searchParams.get("q") ?? undefined;
return NextResponse.json(await pantryCatalog(ctx, q));
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { pantryCreateInput } from "@/lib/api/schemas";
import { listPantry, createPantryItem } from "@/lib/services/pantry";
// Pantry items are consumable Items, so they ride the items:* scopes.
// GET /api/v1/pantry
export async function GET(req: Request) {
try {
const ctx = await authenticateRequest(req, "items:read");
return NextResponse.json(await listPantry(ctx));
} catch (err) {
return handleApiError(err);
}
}
// POST /api/v1/pantry
export async function POST(req: Request) {
try {
const ctx = await authenticateRequest(req, "items:write");
const input = pantryCreateInput.parse(await req.json());
return NextResponse.json(await createPantryItem(ctx, input), { status: 201 });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -57,7 +57,7 @@ function ItemRow({ item, locations, plants, onRefresh }: {
async function handleDelete() {
setDeleting(true);
try {
const res = await fetch(`/api/pantry/${item.id}`, { method: "DELETE" });
const res = await fetch(`/api/v1/pantry/${item.id}`, { method: "DELETE" });
if (res.ok) onRefresh();
} finally {
setDeleting(false);
@@ -207,7 +207,7 @@ export function PantryClient({ initialItems, locations, plants }: {
const [items, setItems] = useState(initialItems);
async function refresh() {
const res = await fetch("/api/pantry");
const res = await fetch("/api/v1/pantry");
if (res.ok) setItems(await res.json());
}

View File

@@ -138,7 +138,7 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
// Load catalog on open (for name autocomplete suggestions)
useEffect(() => {
if (!open) return;
fetch("/api/pantry/catalog")
fetch("/api/v1/pantry/catalog")
.then(r => r.json())
.then(setCatalog)
.catch(() => {});
@@ -160,7 +160,7 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
clearTimeout(debounceRef.current);
if (val.length < 2) { setNameSuggestions([]); setShowDropdown(false); return; }
debounceRef.current = setTimeout(async () => {
const res = await fetch(`/api/pantry/catalog?q=${encodeURIComponent(val)}`);
const res = await fetch(`/api/v1/pantry/catalog?q=${encodeURIComponent(val)}`);
if (!res.ok) return;
const data: CatalogData = await res.json();
setNameSuggestions(data.suggestions.slice(0, 6));
@@ -232,7 +232,7 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
kind: "CONSUMABLE",
tags,
};
const res = await fetch(item ? `/api/pantry/${item.id}` : "/api/pantry", {
const res = await fetch(item ? `/api/v1/pantry/${item.id}` : "/api/v1/pantry", {
method: item ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),

View File

@@ -72,7 +72,7 @@ export function PullDialog({ items, onSuccess }: { items: PantryItem[]; onSucces
setSaving(true);
setError(null);
try {
const res = await fetch("/api/pantry/bulk-use", {
const res = await fetch("/api/v1/pantry/bulk-use", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -80,7 +80,21 @@ export function PullDialog({ items, onSuccess }: { items: PantryItem[]; onSucces
notes: reason.trim() || null,
}),
});
if (!res.ok) throw new Error((await res.json()).error ?? "Failed");
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Failed");
if (data.skipped?.length > 0) {
// Someone else deleted these since the list loaded — say so instead of
// pretending the whole pull worked.
const names = data.skipped.map(
(s: { id: string }) => items.find(i => i.id === s.id)?.name ?? "an item",
);
setPulls({});
setError(
`Couldn't pull ${names.join(", ")} — no longer in the pantry. Everything else was pulled.`,
);
onSuccess();
return;
}
setOpen(false);
reset();
onSuccess();

View File

@@ -43,7 +43,7 @@ export function UseItemDialog({ item, onSuccess }: { item: PantryItem; onSuccess
setSaving(true);
setError(null);
try {
const res = await fetch(`/api/pantry/${item.id}/use`, {
const res = await fetch(`/api/v1/pantry/${item.id}/use`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount: parseFloat(amount), notes: notes.trim() || null }),

View File

@@ -81,6 +81,11 @@ export async function authenticateRequest(req: Request, required?: string): Prom
return { userId: tok.userId, role: tok.user.role, scopes: tok.scopes, via: "token" };
}
return sessionContext();
}
/** AuthContext for server components (cookie session only, full scope). */
export async function sessionContext(): Promise<AuthContext> {
const session = await getSession();
if (!session) throw new ApiError(401, "Unauthorized");
return { userId: session.user.id, role: session.user.role, scopes: ["*"], via: "session" };

View File

@@ -59,12 +59,51 @@ export const itemCreateInput = z.object({
export const itemUpdateInput = itemCreateInput.partial();
export const itemLogInput = z.object({
type: z.enum(["NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED"]),
type: z.enum(["NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED", "USED"]),
notes: z.string().optional(),
cost: z.coerce.number().nonnegative().nullable().optional(),
amount: z.coerce.number().nonnegative().nullable().optional(),
date: z.string().optional(),
});
// --- Pantry (consumable items; the UI's Kitchen → Pantry & Freezer) ---
export const pantryCreateInput = z.object({
name: z.string().min(1),
description: z.string().nullable().optional(),
quantity: z.coerce.number().min(0).default(1),
unit: z.string().nullable().optional(),
packageSize: z.string().nullable().optional(), // legacy alias for description
locationId: z.string().nullable().optional(),
storedAt: z.string().nullable().optional(),
bestBefore: z.string().nullable().optional(),
source: z.enum(["GARDEN", "HOMEMADE", "STORE", "FARMERS_MARKET", "GIFTED", "OTHER"]).nullable().optional(),
plantId: z.string().nullable().optional(),
pantryCategory: z.string().nullable().optional(),
pantrySubcategory: z.string().nullable().optional(),
pricePaid: z.coerce.number().nonnegative().nullable().optional(),
priceUnit: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
tags: z.array(z.string()).optional(),
});
export const pantryUpdateInput = pantryCreateInput.partial();
export const pantryUseInput = z.object({
amount: z.coerce.number().positive(),
notes: z.string().nullable().optional(),
});
export const pantryBulkUseInput = z.object({
pulls: z.array(z.object({ id: z.string(), amount: z.coerce.number().positive() })).min(1),
notes: z.string().nullable().optional(),
});
export type PantryCreateInput = z.infer<typeof pantryCreateInput>;
export type PantryUpdateInput = z.infer<typeof pantryUpdateInput>;
export type PantryUseInput = z.infer<typeof pantryUseInput>;
export type PantryBulkUseInput = z.infer<typeof pantryBulkUseInput>;
export type PlantLogInput = z.infer<typeof plantLogInput>;
export type TaskCreateInput = z.infer<typeof taskCreateInput>;
export type TaskCompleteInput = z.infer<typeof taskCompleteInput>;

View File

@@ -8,6 +8,15 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.23.0",
date: "2026-07-06",
changes: [
"Pantry: pulls are now recorded properly — each one saves the exact amount taken, so we can later see how fast things get used and what a dinner or trip actually consumed.",
"Pantry: if someone else deleted an item while you had the Pull dialog open, it now tells you which items couldn't be pulled instead of quietly skipping them.",
"Behind the scenes: the Pantry now runs on the same modern plumbing as Inventory and Animals, so API tokens (and the future Claude connection) can read and update it too.",
],
},
{
version: "0.22.0",
date: "2026-07-04",

View File

@@ -7,6 +7,7 @@ export const ITEM_LOG_LABELS: Record<ItemLogType, string> = {
PURCHASED: "Purchased",
REPAIRED: "Repaired",
DISPOSED: "Disposed",
USED: "Used",
};
export const ITEM_LOG_COLORS: Record<ItemLogType, string> = {
@@ -16,8 +17,11 @@ export const ITEM_LOG_COLORS: Record<ItemLogType, string> = {
PURCHASED: "text-green-600 dark:text-green-400",
REPAIRED: "text-violet-600 dark:text-violet-400",
DISPOSED: "text-red-600 dark:text-red-400",
USED: "text-teal-600 dark:text-teal-400",
};
// Types offered in the manual "add log" dropdown for durable items.
// USED is written by pantry pulls, not entered by hand, so it's not listed.
export const ITEM_LOG_TYPES: ItemLogType[] = [
"NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED",
];

103
src/lib/pantry/core.test.ts Normal file
View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { normalizeTags, planBulkUse, serializePantryItem, type PantryRow } from "./core";
describe("normalizeTags", () => {
it("trims, drops empties, and dedupes", () => {
expect(normalizeTags([" Poultry ", "Pre-cooked", "", " ", "Poultry"])).toEqual([
"Poultry",
"Pre-cooked",
]);
});
it("keeps distinct casings distinct (labels are case-sensitive)", () => {
expect(normalizeTags(["poultry", "Poultry"])).toEqual(["poultry", "Poultry"]);
});
});
describe("serializePantryItem", () => {
const row: PantryRow = {
id: "i1",
name: "Chicken thighs",
description: "2 lb pack",
quantity: "3.50", // Prisma Decimal stringifies like this
unit: "lbs",
storedAt: new Date("2026-06-01"),
bestBefore: new Date("2026-09-01"),
source: "STORE",
notes: null,
pantryCategory: "Meat",
pantrySubcategory: "Poultry",
pricePaid: "8.99",
priceUnit: "lb",
location: { id: "l1", name: "Chest freezer" },
plant: null,
labels: [{ label: { name: "Pre-cooked" } }],
};
it("converts Decimals to numbers and flattens relations", () => {
const dto = serializePantryItem(row);
expect(dto.quantity).toBe(3.5);
expect(dto.pricePaid).toBe(8.99);
expect(dto.locationId).toBe("l1");
expect(dto.locationName).toBe("Chest freezer");
expect(dto.packageSize).toBe("2 lb pack");
expect(dto.tags).toEqual(["Pre-cooked"]);
});
it("handles null price, location, and plant; formats plant with variety", () => {
const dto = serializePantryItem({
...row,
pricePaid: null,
location: null,
plant: { id: "p1", commonName: "Tomato", variety: "Roma" },
});
expect(dto.pricePaid).toBeNull();
expect(dto.locationId).toBeNull();
expect(dto.plantName).toBe("Tomato (Roma)");
});
});
describe("planBulkUse", () => {
const items = [
{ id: "a", name: "Flour", quantity: 10, unit: "lbs" },
{ id: "b", name: "Eggs", quantity: 12, unit: "each" },
];
it("computes remaining quantities per pull", () => {
const plan = planBulkUse(items, [
{ id: "a", amount: 2 },
{ id: "b", amount: 6 },
]);
expect(plan.skipped).toEqual([]);
expect(plan.updates).toEqual([
{ id: "a", name: "Flour", unit: "lbs", amount: 2, remaining: 8 },
{ id: "b", name: "Eggs", unit: "each", amount: 6, remaining: 6 },
]);
});
it("reports pulls whose item is missing instead of silently dropping them", () => {
const plan = planBulkUse(items, [
{ id: "a", amount: 1 },
{ id: "gone", amount: 5 },
]);
expect(plan.skipped).toEqual(["gone"]);
expect(plan.updates).toHaveLength(1);
});
it("accumulates repeated pulls of the same item", () => {
const plan = planBulkUse(items, [
{ id: "b", amount: 4 },
{ id: "b", amount: 4 },
]);
expect(plan.updates).toEqual([
{ id: "b", name: "Eggs", unit: "each", amount: 8, remaining: 4 },
]);
});
it("caps at zero — never goes negative, and logs only what was there", () => {
const plan = planBulkUse(items, [{ id: "a", amount: 25 }]);
expect(plan.updates).toEqual([
{ id: "a", name: "Flour", unit: "lbs", amount: 10, remaining: 0 },
]);
});
});

125
src/lib/pantry/core.ts Normal file
View File

@@ -0,0 +1,125 @@
// Pure pantry logic — tag normalization, row serialization, and bulk-pull
// planning. No DB access; the service layer (src/lib/services/pantry.ts)
// applies these against Prisma.
/** Trim, drop empties, and dedupe a raw tag list (first occurrence wins). */
export function normalizeTags(tags: string[]): string[] {
return Array.from(new Set(tags.map(t => t.trim()).filter(Boolean)));
}
// The DB row shape the pantry queries select. Decimals arrive as anything
// Number() can coerce (Prisma Decimal serializes via toString).
export type PantryRow = {
id: string;
name: string;
description: string | null;
quantity: unknown;
unit: string | null;
storedAt: Date | null;
bestBefore: Date | null;
source: string | null;
notes: string | null;
pantryCategory: string | null;
pantrySubcategory: string | null;
pricePaid: unknown | null;
priceUnit: string | null;
location: { id: string; name: string } | null;
plant: { id: string; commonName: string; variety: string | null } | null;
labels: { label: { name: string } }[];
};
export type PantryItemDto = {
id: string;
name: string;
description: string | null;
quantity: number;
unit: string | null;
packageSize: string | null;
storedAt: Date | null;
bestBefore: Date | null;
source: string | null;
notes: string | null;
pantryCategory: string | null;
pantrySubcategory: string | null;
pricePaid: number | null;
priceUnit: string | null;
locationId: string | null;
locationName: string | null;
plantId: string | null;
plantName: string | null;
tags: string[];
};
/** Flatten a pantry DB row into the client shape (Decimals → numbers). */
export function serializePantryItem(i: PantryRow): PantryItemDto {
return {
id: i.id,
name: i.name,
description: i.description,
quantity: Number(i.quantity),
unit: i.unit,
packageSize: i.description, // the UI labels `description` as "package size"
storedAt: i.storedAt,
bestBefore: i.bestBefore,
source: i.source,
notes: i.notes,
pantryCategory: i.pantryCategory,
pantrySubcategory: i.pantrySubcategory,
pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
priceUnit: i.priceUnit,
locationId: i.location?.id ?? null,
locationName: i.location?.name ?? null,
plantId: i.plant?.id ?? null,
plantName: i.plant
? `${i.plant.commonName}${i.plant.variety ? ` (${i.plant.variety})` : ""}`
: null,
tags: (i.labels ?? []).map(l => l.label.name),
};
}
export type BulkPull = { id: string; amount: number };
export type BulkUseUpdate = {
id: string;
name: string;
unit: string | null;
amount: number; // how much actually comes off (capped at what's there)
remaining: number;
};
export type BulkUsePlan = {
updates: BulkUseUpdate[];
/** Pull ids that matched no live pantry item — the caller must surface these. */
skipped: string[];
};
/**
* Turn requested pulls into quantity updates against the items on hand.
* Repeated pulls of the same item accumulate; quantities never go below zero.
*/
export function planBulkUse(
items: { id: string; name: string; quantity: number; unit: string | null }[],
pulls: BulkPull[],
): BulkUsePlan {
const remaining = new Map(items.map(i => [i.id, i]));
const updates = new Map<string, BulkUseUpdate>();
const skipped: string[] = [];
for (const pull of pulls) {
const item = remaining.get(pull.id);
if (!item) {
skipped.push(pull.id);
continue;
}
const prev = updates.get(pull.id);
const before = prev ? prev.remaining : item.quantity;
const taken = Math.min(pull.amount, before);
updates.set(pull.id, {
id: item.id,
name: item.name,
unit: item.unit,
amount: (prev?.amount ?? 0) + taken,
remaining: before - taken,
});
}
return { updates: Array.from(updates.values()), skipped };
}

View File

@@ -200,6 +200,7 @@ export async function addItemLog(ctx: AuthContext, itemId: string, input: ItemLo
date: input.date ? new Date(input.date) : new Date(),
notes: input.notes?.trim() || null,
cost: input.cost ?? null,
amount: input.amount ?? null,
actorId: ctx.userId,
},
});

291
src/lib/services/pantry.ts Normal file
View File

@@ -0,0 +1,291 @@
// Pantry service — DB work + AuditEvent for consumable items (Kitchen →
// Pantry & Freezer). Shared by the /api/v1/pantry routes, the UI (which calls
// those routes), and later the MCP server. Pure logic lives in
// src/lib/pantry/core.ts.
import { db } from "@/lib/db";
import { ApiError, type AuthContext } from "@/lib/api/authenticate";
import {
normalizeTags,
planBulkUse,
serializePantryItem,
type PantryItemDto,
} from "@/lib/pantry/core";
import type {
PantryBulkUseInput,
PantryCreateInput,
PantryUpdateInput,
PantryUseInput,
} from "@/lib/api/schemas";
const PANTRY_SELECT = {
id: true, name: true, description: true, quantity: true, unit: true,
storedAt: true, bestBefore: true, source: true, plantId: true, notes: true,
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
location: { select: { id: true, name: true } },
plant: { select: { id: true, commonName: true, variety: true } },
labels: { select: { label: { select: { id: true, name: true } } } },
} as const;
function toDate(s?: string | null): Date | null {
return s ? new Date(s) : null;
}
/** Upsert labels for the tag names, then replace this item's label links. */
async function syncTags(itemId: string, tags: string[]) {
const names = normalizeTags(tags);
const labels = await Promise.all(
names.map(name =>
db.label.upsert({ where: { name }, update: {}, create: { name }, select: { id: true } }),
),
);
await db.labelOnItem.deleteMany({ where: { itemId } });
if (labels.length) {
await db.labelOnItem.createMany({
data: labels.map(l => ({ itemId, labelId: l.id })),
skipDuplicates: true,
});
}
}
export async function listPantry(_ctx: AuthContext): Promise<PantryItemDto[]> {
const items = await db.item.findMany({
where: { kind: "CONSUMABLE", active: true },
orderBy: [{ location: { name: "asc" } }, { name: "asc" }],
select: PANTRY_SELECT,
});
return items.map(serializePantryItem);
}
export async function createPantryItem(
ctx: AuthContext,
input: PantryCreateInput,
): Promise<PantryItemDto> {
const item = await db.item.create({
data: {
name: input.name,
description: input.description ?? input.packageSize ?? null,
kind: "CONSUMABLE",
quantity: input.quantity,
unit: input.unit ?? null,
locationId: input.locationId ?? null,
storedAt: toDate(input.storedAt),
bestBefore: toDate(input.bestBefore),
source: input.source ?? null,
plantId: input.plantId ?? null,
pantryCategory: input.pantryCategory ?? null,
pantrySubcategory: input.pantrySubcategory ?? null,
pricePaid: input.pricePaid ?? null,
priceUnit: input.priceUnit ?? null,
notes: input.notes ?? null,
},
select: { id: true, name: true },
});
if (input.tags?.length) await syncTags(item.id, input.tags);
await db.auditEvent.create({
data: {
action: "pantry.create",
entityId: item.id,
actorId: ctx.userId,
payload: { name: item.name, via: ctx.via },
},
});
const full = await db.item.findUniqueOrThrow({ where: { id: item.id }, select: PANTRY_SELECT });
return serializePantryItem(full);
}
export async function updatePantryItem(
ctx: AuthContext,
id: string,
input: PantryUpdateInput,
): Promise<PantryItemDto> {
const existing = await db.item.findUnique({ where: { id }, select: { active: true, kind: true } });
if (!existing || !existing.active || existing.kind !== "CONSUMABLE")
throw new ApiError(404, "Pantry item not found");
await db.item.update({
where: { id },
data: {
...(input.name !== undefined && { name: input.name }),
...(input.description !== undefined && { description: input.description ?? input.packageSize ?? null }),
...(input.quantity !== undefined && { quantity: input.quantity }),
...(input.unit !== undefined && { unit: input.unit ?? null }),
...(input.locationId !== undefined && { locationId: input.locationId ?? null }),
...(input.storedAt !== undefined && { storedAt: toDate(input.storedAt) }),
...(input.bestBefore !== undefined && { bestBefore: toDate(input.bestBefore) }),
...(input.source !== undefined && { source: input.source ?? null }),
...(input.plantId !== undefined && { plantId: input.plantId ?? null }),
...(input.pantryCategory !== undefined && { pantryCategory: input.pantryCategory ?? null }),
...(input.pantrySubcategory !== undefined && { pantrySubcategory: input.pantrySubcategory ?? null }),
...(input.pricePaid !== undefined && { pricePaid: input.pricePaid ?? null }),
...(input.priceUnit !== undefined && { priceUnit: input.priceUnit ?? null }),
...(input.notes !== undefined && { notes: input.notes ?? null }),
},
});
if (input.tags !== undefined) await syncTags(id, input.tags ?? []);
const item = await db.item.findUniqueOrThrow({ where: { id }, select: PANTRY_SELECT });
await db.auditEvent.create({
data: {
action: "pantry.update",
entityId: id,
actorId: ctx.userId,
payload: { name: item.name, via: ctx.via },
},
});
return serializePantryItem(item);
}
export async function deletePantryItem(ctx: AuthContext, id: string): Promise<void> {
const existing = await db.item.findUnique({ where: { id }, select: { active: true, kind: true, name: true } });
if (!existing || !existing.active || existing.kind !== "CONSUMABLE")
throw new ApiError(404, "Pantry item not found");
await db.item.update({ where: { id }, data: { active: false } });
await db.auditEvent.create({
data: {
action: "pantry.delete",
entityId: id,
actorId: ctx.userId,
payload: { name: existing.name, via: ctx.via },
},
});
}
/** Pull a single item; wraps bulkUsePantry so both paths log identically. */
export async function usePantryItem(
ctx: AuthContext,
id: string,
input: PantryUseInput,
): Promise<{ remaining: number }> {
const result = await bulkUsePantry(ctx, {
pulls: [{ id, amount: input.amount }],
notes: input.notes ?? null,
});
if (result.skipped.length) throw new ApiError(404, "Pantry item not found");
return { remaining: result.results[0].remaining };
}
export type BulkUseResult = {
results: { id: string; remaining: number }[];
/** Requested items that no longer exist — the UI must tell the user. */
skipped: { id: string }[];
};
export async function bulkUsePantry(
ctx: AuthContext,
input: PantryBulkUseInput,
): Promise<BulkUseResult> {
const ids = input.pulls.map(p => p.id);
const notes = input.notes?.trim() || null;
return db.$transaction(async tx => {
const items = await tx.item.findMany({
where: { id: { in: ids }, kind: "CONSUMABLE", active: true },
select: { id: true, name: true, quantity: true, unit: true },
});
const plan = planBulkUse(
items.map(i => ({ id: i.id, name: i.name, quantity: Number(i.quantity), unit: i.unit })),
input.pulls,
);
for (const u of plan.updates) {
await tx.item.update({ where: { id: u.id }, data: { quantity: u.remaining } });
await tx.itemLog.create({
data: {
itemId: u.id,
type: "USED",
amount: u.amount,
notes,
actorId: ctx.userId,
},
});
}
await tx.auditEvent.create({
data: {
action: "pantry.use",
actorId: ctx.userId,
payload: {
pulls: plan.updates.map(u => ({ id: u.id, name: u.name, amount: u.amount })),
skipped: plan.skipped,
notes,
via: ctx.via,
},
},
});
return {
results: plan.updates.map(u => ({ id: u.id, remaining: u.remaining })),
skipped: plan.skipped.map(id => ({ id })),
};
});
}
/** Distinct names + most-recent settings, categories, and labels for autocomplete. */
export async function pantryCatalog(_ctx: AuthContext, q?: string) {
const query = q?.trim() ?? "";
// All consumables, active + inactive, so old entries keep autocompleting.
const items = await db.item.findMany({
where: {
kind: "CONSUMABLE",
...(query ? { name: { contains: query, mode: "insensitive" } } : {}),
},
orderBy: { updatedAt: "desc" },
select: {
name: true, description: true, unit: true, source: true,
pantryCategory: true, pantrySubcategory: true,
priceUnit: true, pricePaid: true,
locationId: true, plantId: true,
location: { select: { id: true, name: true } },
},
take: 200,
});
// Deduplicate by name (case-insensitive), keeping the most-recent per name.
const seen = new Map<string, (typeof items)[number]>();
for (const item of items) {
const key = item.name.toLowerCase();
if (!seen.has(key)) seen.set(key, item);
}
const [allCats, allLabels] = await Promise.all([
db.item.findMany({
where: { kind: "CONSUMABLE", pantryCategory: { not: null } },
select: { pantryCategory: true, pantrySubcategory: true },
distinct: ["pantryCategory", "pantrySubcategory"],
}),
db.label.findMany({ select: { name: true }, orderBy: { name: "asc" } }),
]);
const categories = Array.from(new Set(allCats.map(c => c.pantryCategory).filter(Boolean))) as string[];
const subcategories = allCats
.filter(c => c.pantrySubcategory)
.map(c => ({ category: c.pantryCategory!, subcategory: c.pantrySubcategory! }));
return {
suggestions: Array.from(seen.values()).map(i => ({
name: i.name,
description: i.description,
unit: i.unit,
source: i.source,
pantryCategory: i.pantryCategory,
pantrySubcategory: i.pantrySubcategory,
priceUnit: i.priceUnit,
pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
locationId: i.locationId,
locationName: i.location?.name ?? null,
plantId: i.plantId,
})),
categories,
subcategories,
labels: allLabels.map(l => l.name),
};
}

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.22.0";
export const APP_VERSION = "0.23.0";