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 */} +
+