v0.29.0 — Ramble box, dark-mode toggle, pantry pull reasons

- #10 Ramble box: new /ramble sticky-note board (post/pin/archive/delete),
  ported from FMB. New Ramble model + migration, /api/rambles routes, nav entry.
- #6 Dark-mode toggle: sun/moon button in the top bar (dark palette already
  existed in CSS, just had no control).
- #9 Pantry pull reason: structured disposition (Immediate use / Preserved /
  Gave away / Waste) on ItemLog.reason, added to single + bulk pull dialogs
  and the audit payload. New migration.
- #2 Suggestions: drop the stale white-card wrapper; render SuggestionsPanel
  bare like FMB so it's readable and thumbnails show.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 14:40:54 -05:00
parent 2ad9ac53f8
commit 083e2992c9
18 changed files with 499 additions and 10 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ItemLog" ADD COLUMN "reason" TEXT;

View File

@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "Ramble" (
"id" TEXT NOT NULL,
"body" TEXT NOT NULL,
"pinned" BOOLEAN NOT NULL DEFAULT false,
"archivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Ramble_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Ramble_pinned_archivedAt_createdAt_idx" ON "Ramble"("pinned", "archivedAt", "createdAt");

View File

@@ -72,6 +72,20 @@ model AuditEvent {
@@index([createdAt]) @@index([createdAt])
} }
// Ramble box — sticky-note brain dump for stray homestead thoughts ("move the
// rhubarb", "oil the chainsaw"). Not a Task and not a Location leaf; just a
// free-text jot you pin, archive (soft), or delete (hard). Ported from FMB.
model Ramble {
id String @id @default(cuid())
body String
pinned Boolean @default(false)
archivedAt DateTime? // soft-archive; null = active
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([pinned, archivedAt, createdAt])
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Location — shared hierarchical place tree (garden areas, buildings, rooms, // Location — shared hierarchical place tree (garden areas, buildings, rooms,
// storage bins). The spine for Garden, Inventory, and Pantry. Plants/items/ // storage bins). The spine for Garden, Inventory, and Pantry. Plants/items/
@@ -437,6 +451,7 @@ model ItemLog {
notes String? notes String?
cost Decimal? @db.Decimal(10, 2) cost Decimal? @db.Decimal(10, 2)
amount Decimal? @db.Decimal(10, 2) // quantity consumed, for USED logs (same unit as the item) amount Decimal? @db.Decimal(10, 2) // quantity consumed, for USED logs (same unit as the item)
reason String? // disposition for USED logs: consumed | preserved | gifted | waste (see pantry/core.ts)
actorId String? actorId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@@ -0,0 +1,41 @@
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { RambleClient } from "./ramble-client";
export const metadata: Metadata = { title: "Ramble" };
export const dynamic = "force-dynamic";
export default async function RamblePage() {
const session = await getSession();
if (!session) redirect("/login");
const [active, archived] = await Promise.all([
db.ramble.findMany({
where: { archivedAt: null },
orderBy: [{ pinned: "desc" }, { createdAt: "desc" }],
}),
db.ramble.findMany({
where: { archivedAt: { not: null } },
orderBy: { archivedAt: "desc" },
take: 30,
}),
]);
// Prisma Dates don't serialize across the server/client boundary — send ISO strings.
const serialize = (r: (typeof active)[number]) => ({
id: r.id,
body: r.body,
pinned: r.pinned,
archivedAt: r.archivedAt ? r.archivedAt.toISOString() : null,
createdAt: r.createdAt.toISOString(),
});
return (
<RambleClient
initialActive={active.map(serialize)}
initialArchived={archived.map(serialize)}
/>
);
}

View File

@@ -0,0 +1,212 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { formatDistanceToNow } from "date-fns";
import { Pin, PinOff, Archive, ArchiveRestore, Trash2, StickyNote } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { toast } from "@/hooks/use-toast";
type Ramble = {
id: string;
body: string;
pinned: boolean;
archivedAt: string | null;
createdAt: string;
};
// Rotating sticky-note colors (light + dark variants), keyed off note id so a
// note keeps its color across refreshes rather than reshuffling.
const STICKY = [
"bg-amber-50 border-amber-200 dark:bg-amber-950/40 dark:border-amber-900",
"bg-rose-50 border-rose-200 dark:bg-rose-950/40 dark:border-rose-900",
"bg-emerald-50 border-emerald-200 dark:bg-emerald-950/40 dark:border-emerald-900",
"bg-sky-50 border-sky-200 dark:bg-sky-950/40 dark:border-sky-900",
"bg-violet-50 border-violet-200 dark:bg-violet-950/40 dark:border-violet-900",
];
function stickyColor(id: string): string {
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
return STICKY[h % STICKY.length];
}
export function RambleClient({
initialActive,
initialArchived,
}: {
initialActive: Ramble[];
initialArchived: Ramble[];
}) {
const router = useRouter();
const [, startTransition] = useTransition();
const [body, setBody] = useState("");
const [posting, setPosting] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const active = initialActive;
const archived = initialArchived;
async function post(pinned: boolean) {
const text = body.trim();
if (!text) return;
setPosting(true);
try {
const res = await fetch("/api/rambles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: text, pinned }),
});
if (!res.ok) throw new Error((await res.json()).error ?? "Failed to save");
setBody("");
startTransition(() => router.refresh());
} catch (err) {
toast({ title: "Couldn't save", description: String(err).replace("Error: ", "") });
} finally {
setPosting(false);
}
}
async function mutate(id: string, patch: Record<string, unknown>) {
const res = await fetch(`/api/rambles/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (!res.ok) {
toast({ title: "Couldn't update", description: (await res.json()).error ?? "" });
return;
}
startTransition(() => router.refresh());
}
async function remove(id: string) {
if (!window.confirm("Delete this note? This can't be undone.")) return;
const res = await fetch(`/api/rambles/${id}`, { method: "DELETE" });
if (!res.ok) {
toast({ title: "Couldn't delete", description: (await res.json()).error ?? "" });
return;
}
startTransition(() => router.refresh());
}
return (
<div className="max-w-4xl mx-auto space-y-6">
<div>
<h1 className="font-display text-2xl font-semibold flex items-center gap-2">
<StickyNote className="h-6 w-6 text-[hsl(var(--leaf))]" />
Ramble
</h1>
<p className="text-sm text-muted-foreground mt-1">
A place to dump stray thoughts chores, ideas, reminders that aren&apos;t tasks yet.
</p>
</div>
{/* Composer */}
<div className="rounded-lg border bg-card p-4 space-y-3">
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") post(false);
}}
placeholder="What's on your mind? (⌘/Ctrl+Enter to post)"
rows={3}
className="resize-none"
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => post(true)} disabled={posting || !body.trim()}>
<Pin className="h-4 w-4" /> Post pinned
</Button>
<Button onClick={() => post(false)} disabled={posting || !body.trim()}>
{posting ? "Posting…" : "Post"}
</Button>
</div>
</div>
{/* Active notes */}
{active.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
No notes yet. Jot something above.
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{active.map((r) => (
<NoteCard key={r.id} r={r} onMutate={mutate} onRemove={remove} />
))}
</div>
)}
{/* Archived */}
{archived.length > 0 && (
<div className="pt-2">
<button
onClick={() => setShowArchived((v) => !v)}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{showArchived ? "Hide" : "Show"} archived ({archived.length})
</button>
{showArchived && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 mt-3 opacity-70">
{archived.map((r) => (
<NoteCard key={r.id} r={r} archivedView onMutate={mutate} onRemove={remove} />
))}
</div>
)}
</div>
)}
</div>
);
}
function NoteCard({
r,
archivedView,
onMutate,
onRemove,
}: {
r: Ramble;
archivedView?: boolean;
onMutate: (id: string, patch: Record<string, unknown>) => void;
onRemove: (id: string) => void;
}) {
return (
<div className={cn("group relative rounded-lg border p-3 shadow-sm", stickyColor(r.id))}>
{r.pinned && !archivedView && (
<Pin className="absolute right-2 top-2 h-3.5 w-3.5 text-[hsl(var(--leaf))] fill-current" />
)}
<p className="text-sm whitespace-pre-wrap break-words pr-4 min-h-[2rem]">{r.body}</p>
<div className="mt-2 flex items-center justify-between">
<span className="text-[11px] text-muted-foreground">
{formatDistanceToNow(new Date(r.createdAt), { addSuffix: true })}
</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{!archivedView && (
<button
title={r.pinned ? "Unpin" : "Pin"}
onClick={() => onMutate(r.id, { pinned: !r.pinned })}
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/10"
>
{r.pinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
</button>
)}
<button
title={archivedView ? "Restore" : "Archive"}
onClick={() => onMutate(r.id, { archived: !archivedView })}
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/10"
>
{archivedView ? <ArchiveRestore className="h-3.5 w-3.5" /> : <Archive className="h-3.5 w-3.5" />}
</button>
<button
title="Delete"
onClick={() => onRemove(r.id)}
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/10 text-[hsl(var(--ember))]"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
);
}

View File

@@ -9,12 +9,8 @@ export const dynamic = "force-dynamic";
export default async function SuggestionsPage() { export default async function SuggestionsPage() {
const session = await getSession(); const session = await getSession();
if (!session) redirect("/login"); if (!session) redirect("/login");
// The panel uses light-gray (neutral-*) text that assumes a white backdrop, so // Render the panel bare (matches FMB). Earlier versions needed a white-card
// render it on a white card for readable contrast in light or dark theme. // wrapper for contrast, but the current @otm/account-panel is theme-aware —
// color-scheme:light keeps its native inputs/textarea light-filled too. // the wrapper was fighting its own styles and hiding image thumbnails.
return ( return <SuggestionsPanel />;
<div className="rounded-lg border bg-white text-neutral-900 p-4 md:p-6 shadow-sm [color-scheme:light]">
<SuggestionsPanel />
</div>
);
} }

View File

@@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
export const dynamic = "force-dynamic";
const patchSchema = z.object({
body: z.string().trim().min(1).max(5000).optional(),
pinned: z.boolean().optional(),
archived: z.boolean().optional(),
});
// PATCH /api/rambles/:id — edit body, pin/unpin, or archive/restore.
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
let session;
try {
session = await requireAuth();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let input;
try {
input = patchSchema.parse(await req.json());
} catch (err) {
const msg = err instanceof z.ZodError ? err.errors[0].message : "Invalid input";
return NextResponse.json({ error: msg }, { status: 400 });
}
const data: { body?: string; pinned?: boolean; archivedAt?: Date | null } = {};
if (input.body !== undefined) data.body = input.body;
if (input.pinned !== undefined) data.pinned = input.pinned;
if (input.archived !== undefined) data.archivedAt = input.archived ? new Date() : null;
try {
const ramble = await db.ramble.update({ where: { id: params.id }, data });
await db.auditEvent.create({
data: { action: "ramble.update", entityId: ramble.id, actorId: session.user.id, payload: input },
});
return NextResponse.json(ramble);
} catch {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
}
// DELETE /api/rambles/:id — hard delete (a ramble is disposable by design).
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
let session;
try {
session = await requireAuth();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
await db.ramble.delete({ where: { id: params.id } });
await db.auditEvent.create({
data: { action: "ramble.delete", entityId: params.id, actorId: session.user.id },
});
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
}

View File

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
export const dynamic = "force-dynamic";
const createSchema = z.object({
body: z.string().trim().min(1, "Write something first.").max(5000),
pinned: z.boolean().optional(),
});
// POST /api/rambles — jot a new note.
export async function POST(req: Request) {
let session;
try {
session = await requireAuth();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let input;
try {
input = createSchema.parse(await req.json());
} catch (err) {
const msg = err instanceof z.ZodError ? err.errors[0].message : "Invalid input";
return NextResponse.json({ error: msg }, { status: 400 });
}
const ramble = await db.ramble.create({
data: { body: input.body, pinned: input.pinned ?? false },
});
await db.auditEvent.create({
data: { action: "ramble.create", entityId: ramble.id, actorId: session.user.id },
});
return NextResponse.json(ramble, { status: 201 });
}

View File

@@ -1,6 +1,7 @@
import { import {
LayoutDashboard, Leaf, BookOpen, Package, PawPrint, MapPin, Bell, LayoutDashboard, Leaf, BookOpen, Package, PawPrint, MapPin, Bell,
Lightbulb, KeyRound, HardDrive, RefreshCw, UtensilsCrossed, CalendarDays, Lightbulb, KeyRound, HardDrive, RefreshCw, UtensilsCrossed, CalendarDays,
StickyNote,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
@@ -15,6 +16,7 @@ export const navItems: NavItem[] = [
{ label: "Locations", href: "/locations", icon: MapPin }, { label: "Locations", href: "/locations", icon: MapPin },
{ label: "Reminders", href: "/tasks", icon: Bell }, { label: "Reminders", href: "/tasks", icon: Bell },
{ label: "Calendar", href: "/calendar", icon: CalendarDays }, { label: "Calendar", href: "/calendar", icon: CalendarDays },
{ label: "Ramble", href: "/ramble", icon: StickyNote },
]; ];
export const kitchenItems: NavItem[] = [ export const kitchenItems: NavItem[] = [

View File

@@ -0,0 +1,29 @@
"use client";
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import { Sun, Moon } from "lucide-react";
// Light ↔ dark toggle. The dark palette already lives in globals.css (.dark);
// this just gives it a control. The icon shows the mood you'll switch *to*.
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// Avoid a hydration mismatch: theme isn't known until mounted on the client.
if (!mounted) return <div className="h-9 w-9" aria-hidden />;
const isDark = resolvedTheme === "dark";
return (
<button
type="button"
onClick={() => setTheme(isDark ? "light" : "dark")}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
title={isDark ? "Switch to light mode" : "Switch to dark mode"}
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
{isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
);
}

View File

@@ -12,6 +12,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { MobileNav } from "@/components/layout/mobile-nav"; import { MobileNav } from "@/components/layout/mobile-nav";
import { ThemeToggle } from "@/components/layout/theme-toggle";
import { initials } from "@/lib/utils"; import { initials } from "@/lib/utils";
interface TopNavProps { interface TopNavProps {
@@ -27,6 +28,8 @@ export function TopNav({ user }: TopNavProps) {
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<ThemeToggle />
<Link <Link
href="/suggestions" href="/suggestions"
aria-label="Suggestions & feedback" aria-label="Suggestions & feedback"

View File

@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PANTRY_USE_REASONS, type PantryUseReason } from "@/lib/pantry/core";
import type { PantryItem } from "./pantry-client"; import type { PantryItem } from "./pantry-client";
const QUICK_REASONS = ["Family dinner", "Dinner party", "Taking on a trip", "Meal prep", "Gave it away"]; const QUICK_REASONS = ["Family dinner", "Dinner party", "Taking on a trip", "Meal prep", "Gave it away"];
@@ -17,6 +18,7 @@ export function PullDialog({ items, onSuccess }: { items: PantryItem[]; onSucces
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [pulls, setPulls] = useState<Record<string, Pull>>({}); const [pulls, setPulls] = useState<Record<string, Pull>>({});
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [disposition, setDisposition] = useState<PantryUseReason>("consumed");
const [selectedReason, setSelectedReason] = useState<string | null>(null); const [selectedReason, setSelectedReason] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -51,6 +53,7 @@ export function PullDialog({ items, onSuccess }: { items: PantryItem[]; onSucces
setSearch(""); setSearch("");
setPulls({}); setPulls({});
setReason(""); setReason("");
setDisposition("consumed");
setSelectedReason(null); setSelectedReason(null);
setError(null); setError(null);
} }
@@ -78,6 +81,7 @@ export function PullDialog({ items, onSuccess }: { items: PantryItem[]; onSucces
body: JSON.stringify({ body: JSON.stringify({
pulls: activePulls.map(([id, p]) => ({ id, amount: parseFloat(p.amount) })), pulls: activePulls.map(([id, p]) => ({ id, amount: parseFloat(p.amount) })),
notes: reason.trim() || null, notes: reason.trim() || null,
reason: disposition,
}), }),
}); });
const data = await res.json(); const data = await res.json();
@@ -122,6 +126,17 @@ export function PullDialog({ items, onSuccess }: { items: PantryItem[]; onSucces
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0"> <form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
{/* Reason */} {/* Reason */}
<div className="px-6 pt-4 pb-3 border-b space-y-2 shrink-0"> <div className="px-6 pt-4 pb-3 border-b space-y-2 shrink-0">
<div className="flex flex-wrap gap-1.5">
{PANTRY_USE_REASONS.map(r => (
<button key={r.value} type="button" onClick={() => setDisposition(r.value)}
className={cn("rounded-full border px-3 py-1 text-xs font-medium transition-colors",
disposition === r.value ? "bg-primary text-primary-foreground border-primary" : "bg-background hover:bg-accent"
)}>
{r.label}
</button>
))}
</div>
<Label className="text-xs text-muted-foreground">What's it for? (optional)</Label>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{QUICK_REASONS.map(r => ( {QUICK_REASONS.map(r => (
<button key={r} type="button" onClick={() => pickReason(r)} <button key={r} type="button" onClick={() => pickReason(r)}

View File

@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PANTRY_USE_REASONS, type PantryUseReason } from "@/lib/pantry/core";
import type { PantryItem } from "./pantry-client"; import type { PantryItem } from "./pantry-client";
const QUICK_REASONS = ["Family dinner", "Dinner party", "Taking on a trip", "Meal prep", "Gave it away"]; const QUICK_REASONS = ["Family dinner", "Dinner party", "Taking on a trip", "Meal prep", "Gave it away"];
@@ -14,6 +15,7 @@ export function UseItemDialog({ item, onSuccess }: { item: PantryItem; onSuccess
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [amount, setAmount] = useState("1"); const [amount, setAmount] = useState("1");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [disposition, setDisposition] = useState<PantryUseReason>("consumed");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedReason, setSelectedReason] = useState<string | null>(null); const [selectedReason, setSelectedReason] = useState<string | null>(null);
@@ -46,12 +48,13 @@ export function UseItemDialog({ item, onSuccess }: { item: PantryItem; onSuccess
const res = await fetch(`/api/v1/pantry/${item.id}/use`, { const res = await fetch(`/api/v1/pantry/${item.id}/use`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount: parseFloat(amount), notes: notes.trim() || null }), body: JSON.stringify({ amount: parseFloat(amount), notes: notes.trim() || null, reason: disposition }),
}); });
if (!res.ok) throw new Error((await res.json()).error ?? "Failed"); if (!res.ok) throw new Error((await res.json()).error ?? "Failed");
setOpen(false); setOpen(false);
setAmount("1"); setAmount("1");
setNotes(""); setNotes("");
setDisposition("consumed");
setSelectedReason(null); setSelectedReason(null);
onSuccess(); onSuccess();
} catch (err) { } catch (err) {
@@ -78,6 +81,26 @@ export function UseItemDialog({ item, onSuccess }: { item: PantryItem; onSuccess
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Currently: <strong>{item.quantity} {item.unit ?? ""}</strong> Currently: <strong>{item.quantity} {item.unit ?? ""}</strong>
</p> </p>
<div className="space-y-1.5">
<Label>Reason</Label>
<div className="flex flex-wrap gap-1.5">
{PANTRY_USE_REASONS.map(r => (
<button
key={r.value}
type="button"
onClick={() => setDisposition(r.value)}
className={cn(
"rounded-full border px-3 py-1 text-xs transition-colors",
disposition === r.value
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent"
)}
>
{r.label}
</button>
))}
</div>
</div>
<div className="space-y-1"> <div className="space-y-1">
<Label>How much are you using? ({item.unit ?? "units"})</Label> <Label>How much are you using? ({item.unit ?? "units"})</Label>
<input <input

View File

@@ -1,6 +1,7 @@
// Zod schemas shared by the REST API and (later) the MCP tool definitions, so // Zod schemas shared by the REST API and (later) the MCP tool definitions, so
// validation lives in exactly one place. // validation lives in exactly one place.
import { z } from "zod"; import { z } from "zod";
import { PANTRY_USE_REASON_VALUES } from "@/lib/pantry/core";
export const plantLogInput = z.object({ export const plantLogInput = z.object({
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]), type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
@@ -101,11 +102,13 @@ export const pantryUpdateInput = pantryCreateInput.partial();
export const pantryUseInput = z.object({ export const pantryUseInput = z.object({
amount: z.coerce.number().positive(), amount: z.coerce.number().positive(),
notes: z.string().nullable().optional(), notes: z.string().nullable().optional(),
reason: z.enum(PANTRY_USE_REASON_VALUES).nullable().optional(),
}); });
export const pantryBulkUseInput = z.object({ export const pantryBulkUseInput = z.object({
pulls: z.array(z.object({ id: z.string(), amount: z.coerce.number().positive() })).min(1), pulls: z.array(z.object({ id: z.string(), amount: z.coerce.number().positive() })).min(1),
notes: z.string().nullable().optional(), notes: z.string().nullable().optional(),
reason: z.enum(PANTRY_USE_REASON_VALUES).nullable().optional(),
}); });
export type PantryCreateInput = z.infer<typeof pantryCreateInput>; export type PantryCreateInput = z.infer<typeof pantryCreateInput>;

View File

@@ -8,6 +8,16 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.29.0",
date: "2026-07-12",
changes: [
"New Ramble page — a sticky-note board for dumping stray thoughts and ideas that aren't tasks yet. Pin the important ones, archive the ones you're done with.",
"Dark mode is back: a sun/moon button in the top bar flips the whole app between light and dark.",
"When you pull something from the pantry you can now say why — Immediate use, Preserved, Gave away, or Waste — so we can actually see what's getting eaten vs. thrown out.",
"Cleaned up the Suggestions page so it's readable and photo thumbnails show up.",
],
},
{ {
version: "0.28.1", version: "0.28.1",
date: "2026-07-10", date: "2026-07-10",

View File

@@ -2,6 +2,23 @@
// planning. No DB access; the service layer (src/lib/services/pantry.ts) // planning. No DB access; the service layer (src/lib/services/pantry.ts)
// applies these against Prisma. // applies these against Prisma.
// Disposition for a pantry pull — WHY it left inventory, kept structured so
// waste vs. use is queryable (don't fold this into freeform notes). Stored as
// the slug string on ItemLog.reason; null for old rows and API pulls that omit it.
export const PANTRY_USE_REASONS = [
{ value: "consumed", label: "Immediate use" },
{ value: "preserved", label: "Preserved / stored" },
{ value: "gifted", label: "Gave away" },
{ value: "waste", label: "Waste / spoiled" },
] as const;
export type PantryUseReason = (typeof PANTRY_USE_REASONS)[number]["value"];
export const PANTRY_USE_REASON_VALUES = PANTRY_USE_REASONS.map(r => r.value) as [
PantryUseReason,
...PantryUseReason[],
];
/** Trim, drop empties, and dedupe a raw tag list (first occurrence wins). */ /** Trim, drop empties, and dedupe a raw tag list (first occurrence wins). */
export function normalizeTags(tags: string[]): string[] { export function normalizeTags(tags: string[]): string[] {
return Array.from(new Set(tags.map(t => t.trim()).filter(Boolean))); return Array.from(new Set(tags.map(t => t.trim()).filter(Boolean)));

View File

@@ -166,6 +166,7 @@ export async function usePantryItem(
const result = await bulkUsePantry(ctx, { const result = await bulkUsePantry(ctx, {
pulls: [{ id, amount: input.amount }], pulls: [{ id, amount: input.amount }],
notes: input.notes ?? null, notes: input.notes ?? null,
reason: input.reason ?? null,
}); });
if (result.skipped.length) throw new ApiError(404, "Pantry item not found"); if (result.skipped.length) throw new ApiError(404, "Pantry item not found");
return { remaining: result.results[0].remaining }; return { remaining: result.results[0].remaining };
@@ -183,6 +184,7 @@ export async function bulkUsePantry(
): Promise<BulkUseResult> { ): Promise<BulkUseResult> {
const ids = input.pulls.map(p => p.id); const ids = input.pulls.map(p => p.id);
const notes = input.notes?.trim() || null; const notes = input.notes?.trim() || null;
const reason = input.reason ?? null;
return db.$transaction(async tx => { return db.$transaction(async tx => {
const items = await tx.item.findMany({ const items = await tx.item.findMany({
@@ -203,6 +205,7 @@ export async function bulkUsePantry(
type: "USED", type: "USED",
amount: u.amount, amount: u.amount,
notes, notes,
reason,
actorId: ctx.userId, actorId: ctx.userId,
}, },
}); });
@@ -216,6 +219,7 @@ export async function bulkUsePantry(
pulls: plan.updates.map(u => ({ id: u.id, name: u.name, amount: u.amount })), pulls: plan.updates.map(u => ({ id: u.id, name: u.name, amount: u.amount })),
skipped: plan.skipped, skipped: plan.skipped,
notes, notes,
reason,
via: ctx.via, via: ctx.via,
}, },
}, },

View File

@@ -1,2 +1,2 @@
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`. // Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
export const APP_VERSION = "0.28.1"; export const APP_VERSION = "0.29.0";