From 083e2992c95cc547cfb80e3d9ee28bb497377135 Mon Sep 17 00:00:00 2001 From: tonym Date: Sun, 12 Jul 2026 14:40:54 -0500 Subject: [PATCH] =?UTF-8?q?v0.29.0=20=E2=80=94=20Ramble=20box,=20dark-mode?= =?UTF-8?q?=20toggle,=20pantry=20pull=20reasons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #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 --- .../migration.sql | 2 + .../migration.sql | 14 ++ prisma/schema.prisma | 15 ++ src/app/(dashboard)/ramble/page.tsx | 41 ++++ src/app/(dashboard)/ramble/ramble-client.tsx | 212 ++++++++++++++++++ src/app/(dashboard)/suggestions/page.tsx | 12 +- src/app/api/rambles/[id]/route.ts | 65 ++++++ src/app/api/rambles/route.ts | 38 ++++ src/components/layout/nav-items.ts | 2 + src/components/layout/theme-toggle.tsx | 29 +++ src/components/layout/topnav.tsx | 3 + src/components/pantry/pull-dialog.tsx | 15 ++ src/components/pantry/use-item-dialog.tsx | 25 ++- src/lib/api/schemas.ts | 3 + src/lib/changelog.ts | 10 + src/lib/pantry/core.ts | 17 ++ src/lib/services/pantry.ts | 4 + src/lib/version.ts | 2 +- 18 files changed, 499 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/20260712185913_v0_29_pantry_use_reason/migration.sql create mode 100644 prisma/migrations/20260712190320_v0_29_ramble_box/migration.sql create mode 100644 src/app/(dashboard)/ramble/page.tsx create mode 100644 src/app/(dashboard)/ramble/ramble-client.tsx create mode 100644 src/app/api/rambles/[id]/route.ts create mode 100644 src/app/api/rambles/route.ts create mode 100644 src/components/layout/theme-toggle.tsx diff --git a/prisma/migrations/20260712185913_v0_29_pantry_use_reason/migration.sql b/prisma/migrations/20260712185913_v0_29_pantry_use_reason/migration.sql new file mode 100644 index 0000000..93507c5 --- /dev/null +++ b/prisma/migrations/20260712185913_v0_29_pantry_use_reason/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ItemLog" ADD COLUMN "reason" TEXT; diff --git a/prisma/migrations/20260712190320_v0_29_ramble_box/migration.sql b/prisma/migrations/20260712190320_v0_29_ramble_box/migration.sql new file mode 100644 index 0000000..691c507 --- /dev/null +++ b/prisma/migrations/20260712190320_v0_29_ramble_box/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dd889f9..6ae1527 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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()) diff --git a/src/app/(dashboard)/ramble/page.tsx b/src/app/(dashboard)/ramble/page.tsx new file mode 100644 index 0000000..eb20aae --- /dev/null +++ b/src/app/(dashboard)/ramble/page.tsx @@ -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 ( + + ); +} diff --git a/src/app/(dashboard)/ramble/ramble-client.tsx b/src/app/(dashboard)/ramble/ramble-client.tsx new file mode 100644 index 0000000..87e0904 --- /dev/null +++ b/src/app/(dashboard)/ramble/ramble-client.tsx @@ -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) { + 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 ( +
+
+

+ + Ramble +

+

+ A place to dump stray thoughts — chores, ideas, reminders that aren't tasks yet. +

+
+ + {/* Composer */} +
+