diff --git a/prisma/migrations/20260616072700_v0_18_task_location/migration.sql b/prisma/migrations/20260616072700_v0_18_task_location/migration.sql new file mode 100644 index 0000000..6f44b37 --- /dev/null +++ b/prisma/migrations/20260616072700_v0_18_task_location/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "Task" ADD COLUMN "locationId" TEXT; + +-- CreateIndex +CREATE INDEX "Task_locationId_idx" ON "Task"("locationId"); + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c34491c..a6138f2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -102,6 +102,7 @@ model Location { plants Plant[] items Item[] animals Animal[] + tasks Task[] @@index([parentId]) @@index([kind]) @@ -242,6 +243,8 @@ model Task { item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull) animalId String? animal Animal? @relation(fields: [animalId], references: [id], onDelete: SetNull) + locationId String? + location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull) recurrence RecurrenceType @default(YEARLY) intervalDays Int? // for CUSTOM: every N days month Int? // 1–12: for YEARLY, which month @@ -256,6 +259,7 @@ model Task { @@index([plantId]) @@index([itemId]) @@index([animalId]) + @@index([locationId]) @@index([active]) } diff --git a/src/app/(dashboard)/tasks/page.tsx b/src/app/(dashboard)/tasks/page.tsx index 36893f6..5d023fc 100644 --- a/src/app/(dashboard)/tasks/page.tsx +++ b/src/app/(dashboard)/tasks/page.tsx @@ -28,7 +28,12 @@ export default async function TasksPage() { const tasks = await db.task.findMany({ where: { active: true }, orderBy: { nextDue: "asc" }, - include: { plant: { select: { id: true, commonName: true, variety: true } } }, + include: { + plant: { select: { id: true, commonName: true, variety: true } }, + item: { select: { id: true, name: true } }, + animal: { select: { id: true, name: true } }, + location: { select: { id: true, name: true, path: true } }, + }, }); const overdue = tasks.filter((t) => isOverdue(t.nextDue)); @@ -104,6 +109,9 @@ function Section({ title, icon: Icon, iconClass, children }: { function TaskCard({ task, overdue = false }: { task: Awaited>[0] & { plant: { id: string; commonName: string; variety: string | null } | null; + item: { id: string; name: string } | null; + animal: { id: string; name: string } | null; + location: { id: string; name: string; path: string | null } | null; }; overdue?: boolean; }) { @@ -124,10 +132,23 @@ function TaskCard({ task, overdue = false }: { {task.plant && ( - + {task.plant.commonName}{task.plant.variety ? ` · ${task.plant.variety}` : ""} )} + {task.item && ( + + {task.item.name} + + )} + {task.animal && ( + + {task.animal.name} + + )} + {task.location && ( + 📍 {task.location.path || task.location.name} + )} {task.notes && (

{task.notes}

diff --git a/src/app/api/locations/bulk/route.ts b/src/app/api/locations/bulk/route.ts new file mode 100644 index 0000000..17fe5d8 --- /dev/null +++ b/src/app/api/locations/bulk/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { authenticateRequest, handleApiError } from "@/lib/api/authenticate"; +import { bulkSetupLocations } from "@/lib/services/location-setup"; + +const schema = z.object({ + outline: z.string().min(1), + groupGardenUnder: z.string().optional(), +}); + +// POST /api/locations/bulk — create a Location tree from an indented outline, +// optionally sweeping existing top-level garden areas under a named parent. +export async function POST(req: Request) { + try { + const ctx = await authenticateRequest(req, "locations:write"); + const { outline, groupGardenUnder } = schema.parse(await req.json()); + const summary = await bulkSetupLocations(ctx, { outline, groupGardenUnder }); + return NextResponse.json(summary, { status: 201 }); + } catch (err) { + return handleApiError(err); + } +} diff --git a/src/components/locations/location-manager.tsx b/src/components/locations/location-manager.tsx index 373fe94..168af42 100644 --- a/src/components/locations/location-manager.tsx +++ b/src/components/locations/location-manager.tsx @@ -16,8 +16,10 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { useToast } from "@/hooks/use-toast"; -import { Plus, MoreVertical, MapPin, Building2, DoorOpen, Box, Trees, HelpCircle } from "lucide-react"; +import { Plus, MoreVertical, MapPin, Building2, DoorOpen, Box, Trees, HelpCircle, Bell } from "lucide-react"; import { buildTree, flattenForSelect } from "@/lib/locations/tree"; +import { MONTH_NAMES } from "@/lib/tasks/schedule"; +import { QuickSetupButton } from "@/components/locations/quick-setup-button"; type Loc = { id: string; @@ -38,6 +40,10 @@ const NONE = "__none__"; export function LocationManager({ locations }: { locations: Loc[] }) { const [dialog, setDialog] = useState(null); + const [reminder, setReminder] = useState(null); const [busy, setBusy] = useState(false); const router = useRouter(); const { toast } = useToast(); @@ -84,6 +90,28 @@ export function LocationManager({ locations }: { locations: Loc[] }) { router.refresh(); } + async function saveReminder() { + if (!reminder || !reminder.title.trim()) return; + const body: Record = { + title: reminder.title.trim(), + category: "HOME", + locationId: reminder.locationId, + recurrence: reminder.recurrence, + nextDue: new Date(reminder.nextDue).toISOString(), + }; + if (reminder.recurrence === "CUSTOM") body.intervalDays = parseInt(reminder.intervalDays); + if (reminder.recurrence === "YEARLY") body.month = parseInt(reminder.month); + setBusy(true); + const res = await fetch("/api/v1/tasks", { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + }); + setBusy(false); + if (!res.ok) { toast({ title: "Error", description: "Could not save reminder.", variant: "destructive" }); return; } + toast({ title: "Reminder added", description: "It'll show in Reminders." }); + setReminder(null); + router.refresh(); + } + // Valid parents for the dialog (exclude self when editing — server guards descendants). const parentChoices = flat.filter((n) => !(dialog?.mode === "edit" && n.id === dialog.id)); @@ -93,7 +121,10 @@ export function LocationManager({ locations }: { locations: Loc[] }) {

The shared place tree — garden areas, buildings, rooms, and storage. Plants and items live here.

- +
+ + +
{locations.length === 0 ? ( @@ -122,6 +153,9 @@ export function LocationManager({ locations }: { locations: Loc[] }) { openAdd(n.id)}>Add sub-location openEdit(n as unknown as Loc)}>Rename / move + setReminder({ locationId: n.id, locationName: n.name, title: "", recurrence: "YEARLY", intervalDays: "90", month: "1", nextDue: new Date().toISOString().slice(0, 10) })}> + Add reminder + remove(n as unknown as Loc)}>Delete @@ -171,6 +205,60 @@ export function LocationManager({ locations }: { locations: Loc[] }) { )} + + { if (!v) setReminder(null); }}> + + + Reminder for {reminder?.locationName} + + {reminder && ( +
{ e.preventDefault(); saveReminder(); }} className="space-y-4"> +
+ + setReminder({ ...reminder, title: e.target.value })} + placeholder="Clean gutters, change furnace filter…" autoFocus /> +
+
+ + +
+ {reminder.recurrence === "CUSTOM" && ( +
+ + setReminder({ ...reminder, intervalDays: e.target.value })} /> +
+ )} + {reminder.recurrence === "YEARLY" && ( +
+ + +
+ )} +
+ + setReminder({ ...reminder, nextDue: e.target.value })} /> +
+ + + + +
+ )} +
+
); } diff --git a/src/components/locations/quick-setup-button.tsx b/src/components/locations/quick-setup-button.tsx new file mode 100644 index 0000000..5f02af9 --- /dev/null +++ b/src/components/locations/quick-setup-button.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, +} from "@/components/ui/dialog"; +import { useToast } from "@/hooks/use-toast"; +import { Wand2 } from "lucide-react"; + +// Pre-filled with the Moon homestead house layout — Tony just clicks Create. +const DEFAULT_OUTLINE = `House + Main floor + Entrance + Living room + Family room + Dining room + Kitchen + Pantry + Office + Bedroom + Guest room + Bathroom + Laundry room + Basement + Stairs + Laundry room + Furnace room + Workshop + Studio + Bathroom + Garage + Exterior + Gutters + Exterior lights + Roof +Outbuildings + Brown shed + Green shed + Wood shed +Yard`; + +export function QuickSetupButton() { + const [open, setOpen] = useState(false); + const [outline, setOutline] = useState(DEFAULT_OUTLINE); + const [groupGarden, setGroupGarden] = useState(true); + const [busy, setBusy] = useState(false); + const router = useRouter(); + const { toast } = useToast(); + + async function run() { + setBusy(true); + const res = await fetch("/api/locations/bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ outline, groupGardenUnder: groupGarden ? "Yard" : undefined }), + }); + setBusy(false); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + toast({ title: "Error", description: err.error ?? "Setup failed.", variant: "destructive" }); + return; + } + const s = await res.json(); + toast({ + title: "Places set up!", + description: `${s.created} created${s.moved ? `, ${s.moved} garden areas grouped under Yard` : ""}.`, + }); + setOpen(false); + router.refresh(); + } + + return ( + <> + + + + + Quick setup from an outline + + Each indent is a sub-location. Re-running is safe — existing places are reused, not duplicated. + + +
+