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:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ItemLog" ADD COLUMN "reason" TEXT;
|
||||
@@ -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");
|
||||
@@ -72,6 +72,20 @@ model AuditEvent {
|
||||
@@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,
|
||||
// storage bins). The spine for Garden, Inventory, and Pantry. Plants/items/
|
||||
@@ -437,6 +451,7 @@ model ItemLog {
|
||||
notes String?
|
||||
cost Decimal? @db.Decimal(10, 2)
|
||||
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?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
|
||||
41
src/app/(dashboard)/ramble/page.tsx
Normal file
41
src/app/(dashboard)/ramble/page.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
212
src/app/(dashboard)/ramble/ramble-client.tsx
Normal file
212
src/app/(dashboard)/ramble/ramble-client.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -9,12 +9,8 @@ export const dynamic = "force-dynamic";
|
||||
export default async function SuggestionsPage() {
|
||||
const session = await getSession();
|
||||
if (!session) redirect("/login");
|
||||
// The panel uses light-gray (neutral-*) text that assumes a white backdrop, so
|
||||
// render it on a white card for readable contrast in light or dark theme.
|
||||
// color-scheme:light keeps its native inputs/textarea light-filled too.
|
||||
return (
|
||||
<div className="rounded-lg border bg-white text-neutral-900 p-4 md:p-6 shadow-sm [color-scheme:light]">
|
||||
<SuggestionsPanel />
|
||||
</div>
|
||||
);
|
||||
// Render the panel bare (matches FMB). Earlier versions needed a white-card
|
||||
// wrapper for contrast, but the current @otm/account-panel is theme-aware —
|
||||
// the wrapper was fighting its own styles and hiding image thumbnails.
|
||||
return <SuggestionsPanel />;
|
||||
}
|
||||
|
||||
65
src/app/api/rambles/[id]/route.ts
Normal file
65
src/app/api/rambles/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
38
src/app/api/rambles/route.ts
Normal file
38
src/app/api/rambles/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
LayoutDashboard, Leaf, BookOpen, Package, PawPrint, MapPin, Bell,
|
||||
Lightbulb, KeyRound, HardDrive, RefreshCw, UtensilsCrossed, CalendarDays,
|
||||
StickyNote,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -15,6 +16,7 @@ export const navItems: NavItem[] = [
|
||||
{ label: "Locations", href: "/locations", icon: MapPin },
|
||||
{ label: "Reminders", href: "/tasks", icon: Bell },
|
||||
{ label: "Calendar", href: "/calendar", icon: CalendarDays },
|
||||
{ label: "Ramble", href: "/ramble", icon: StickyNote },
|
||||
];
|
||||
|
||||
export const kitchenItems: NavItem[] = [
|
||||
|
||||
29
src/components/layout/theme-toggle.tsx
Normal file
29
src/components/layout/theme-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { MobileNav } from "@/components/layout/mobile-nav";
|
||||
import { ThemeToggle } from "@/components/layout/theme-toggle";
|
||||
import { initials } from "@/lib/utils";
|
||||
|
||||
interface TopNavProps {
|
||||
@@ -27,6 +28,8 @@ export function TopNav({ user }: TopNavProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<ThemeToggle />
|
||||
|
||||
<Link
|
||||
href="/suggestions"
|
||||
aria-label="Suggestions & feedback"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PANTRY_USE_REASONS, type PantryUseReason } from "@/lib/pantry/core";
|
||||
import type { PantryItem } from "./pantry-client";
|
||||
|
||||
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 [pulls, setPulls] = useState<Record<string, Pull>>({});
|
||||
const [reason, setReason] = useState("");
|
||||
const [disposition, setDisposition] = useState<PantryUseReason>("consumed");
|
||||
const [selectedReason, setSelectedReason] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -51,6 +53,7 @@ export function PullDialog({ items, onSuccess }: { items: PantryItem[]; onSucces
|
||||
setSearch("");
|
||||
setPulls({});
|
||||
setReason("");
|
||||
setDisposition("consumed");
|
||||
setSelectedReason(null);
|
||||
setError(null);
|
||||
}
|
||||
@@ -78,6 +81,7 @@ export function PullDialog({ items, onSuccess }: { items: PantryItem[]; onSucces
|
||||
body: JSON.stringify({
|
||||
pulls: activePulls.map(([id, p]) => ({ id, amount: parseFloat(p.amount) })),
|
||||
notes: reason.trim() || null,
|
||||
reason: disposition,
|
||||
}),
|
||||
});
|
||||
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">
|
||||
{/* Reason */}
|
||||
<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">
|
||||
{QUICK_REASONS.map(r => (
|
||||
<button key={r} type="button" onClick={() => pickReason(r)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PANTRY_USE_REASONS, type PantryUseReason } from "@/lib/pantry/core";
|
||||
import type { PantryItem } from "./pantry-client";
|
||||
|
||||
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 [amount, setAmount] = useState("1");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [disposition, setDisposition] = useState<PantryUseReason>("consumed");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = 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`, {
|
||||
method: "POST",
|
||||
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");
|
||||
setOpen(false);
|
||||
setAmount("1");
|
||||
setNotes("");
|
||||
setDisposition("consumed");
|
||||
setSelectedReason(null);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
@@ -78,6 +81,26 @@ export function UseItemDialog({ item, onSuccess }: { item: PantryItem; onSuccess
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Currently: <strong>{item.quantity} {item.unit ?? ""}</strong>
|
||||
</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">
|
||||
<Label>How much are you using? ({item.unit ?? "units"})</Label>
|
||||
<input
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Zod schemas shared by the REST API and (later) the MCP tool definitions, so
|
||||
// validation lives in exactly one place.
|
||||
import { z } from "zod";
|
||||
import { PANTRY_USE_REASON_VALUES } from "@/lib/pantry/core";
|
||||
|
||||
export const plantLogInput = z.object({
|
||||
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
|
||||
@@ -101,11 +102,13 @@ export const pantryUpdateInput = pantryCreateInput.partial();
|
||||
export const pantryUseInput = z.object({
|
||||
amount: z.coerce.number().positive(),
|
||||
notes: z.string().nullable().optional(),
|
||||
reason: z.enum(PANTRY_USE_REASON_VALUES).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(),
|
||||
reason: z.enum(PANTRY_USE_REASON_VALUES).nullable().optional(),
|
||||
});
|
||||
|
||||
export type PantryCreateInput = z.infer<typeof pantryCreateInput>;
|
||||
|
||||
@@ -8,6 +8,16 @@ export type 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",
|
||||
date: "2026-07-10",
|
||||
|
||||
@@ -2,6 +2,23 @@
|
||||
// planning. No DB access; the service layer (src/lib/services/pantry.ts)
|
||||
// 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). */
|
||||
export function normalizeTags(tags: string[]): string[] {
|
||||
return Array.from(new Set(tags.map(t => t.trim()).filter(Boolean)));
|
||||
|
||||
@@ -166,6 +166,7 @@ export async function usePantryItem(
|
||||
const result = await bulkUsePantry(ctx, {
|
||||
pulls: [{ id, amount: input.amount }],
|
||||
notes: input.notes ?? null,
|
||||
reason: input.reason ?? null,
|
||||
});
|
||||
if (result.skipped.length) throw new ApiError(404, "Pantry item not found");
|
||||
return { remaining: result.results[0].remaining };
|
||||
@@ -183,6 +184,7 @@ export async function bulkUsePantry(
|
||||
): Promise<BulkUseResult> {
|
||||
const ids = input.pulls.map(p => p.id);
|
||||
const notes = input.notes?.trim() || null;
|
||||
const reason = input.reason ?? null;
|
||||
|
||||
return db.$transaction(async tx => {
|
||||
const items = await tx.item.findMany({
|
||||
@@ -203,6 +205,7 @@ export async function bulkUsePantry(
|
||||
type: "USED",
|
||||
amount: u.amount,
|
||||
notes,
|
||||
reason,
|
||||
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 })),
|
||||
skipped: plan.skipped,
|
||||
notes,
|
||||
reason,
|
||||
via: ctx.via,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// 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";
|
||||
|
||||
Reference in New Issue
Block a user