v0.18.0 — Locations Quick setup + place-based reminders (#4 groundwork)
#4 cleanup + Tony's house structure. - Task.locationId (migration): reminders can attach to a place, not just a plant/item/animal. Add one from a location's menu (e.g. clean gutters on House > Exterior); the Reminders page + tasks service surface the linked location/item/ animal. - Locations "Quick setup": paste/confirm an indented outline → creates the nested tree (find-or-create by name+parent, idempotent), pre-filled with the Moon homestead house layout. Optional "sweep existing garden areas under Yard" moves every top-level place that holds plants under a new Yard parent — the 'move all the yard stuff to a new top level' ask. - Pure outline parser (src/lib/locations/outline.ts, +4 tests); bulk setup service + /api/locations/bulk. Verified end-to-end: nested tree + paths, garden sweep, place reminder. Yard sub-bucketing deferred per Tony. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ReturnType<typeof db.task.findMany>>[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 }: {
|
||||
</div>
|
||||
|
||||
{task.plant && (
|
||||
<Link href={`/garden/${task.plant.id}`} className="text-xs text-[hsl(var(--leaf))] hover:underline">
|
||||
<Link href={`/garden/${task.plant.id}`} className="text-xs text-[hsl(var(--leaf))] hover:underline block">
|
||||
{task.plant.commonName}{task.plant.variety ? ` · ${task.plant.variety}` : ""}
|
||||
</Link>
|
||||
)}
|
||||
{task.item && (
|
||||
<Link href={`/inventory/${task.item.id}`} className="text-xs text-[hsl(var(--leaf))] hover:underline block">
|
||||
{task.item.name}
|
||||
</Link>
|
||||
)}
|
||||
{task.animal && (
|
||||
<Link href={`/animals/${task.animal.id}`} className="text-xs text-[hsl(var(--leaf))] hover:underline block">
|
||||
{task.animal.name}
|
||||
</Link>
|
||||
)}
|
||||
{task.location && (
|
||||
<span className="text-xs text-muted-foreground">📍 {task.location.path || task.location.name}</span>
|
||||
)}
|
||||
|
||||
{task.notes && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{task.notes}</p>
|
||||
|
||||
22
src/app/api/locations/bulk/route.ts
Normal file
22
src/app/api/locations/bulk/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 | { mode: "add" | "edit"; id?: string; name: string; kind: string; parentId: string }>(null);
|
||||
const [reminder, setReminder] = useState<null | {
|
||||
locationId: string; locationName: string; title: string;
|
||||
recurrence: string; intervalDays: string; month: string; nextDue: string;
|
||||
}>(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<string, unknown> = {
|
||||
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[] }) {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The shared place tree — garden areas, buildings, rooms, and storage. Plants and items live here.
|
||||
</p>
|
||||
<Button size="sm" onClick={() => openAdd()}><Plus className="h-4 w-4 mr-1" />Add location</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<QuickSetupButton />
|
||||
<Button size="sm" onClick={() => openAdd()}><Plus className="h-4 w-4 mr-1" />Add location</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{locations.length === 0 ? (
|
||||
@@ -122,6 +153,9 @@ export function LocationManager({ locations }: { locations: Loc[] }) {
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openAdd(n.id)}>Add sub-location</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openEdit(n as unknown as Loc)}>Rename / move</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setReminder({ locationId: n.id, locationName: n.name, title: "", recurrence: "YEARLY", intervalDays: "90", month: "1", nextDue: new Date().toISOString().slice(0, 10) })}>
|
||||
Add reminder
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-[hsl(var(--ember))]" onClick={() => remove(n as unknown as Loc)}>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -171,6 +205,60 @@ export function LocationManager({ locations }: { locations: Loc[] }) {
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!reminder} onOpenChange={(v) => { if (!v) setReminder(null); }}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reminder for {reminder?.locationName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{reminder && (
|
||||
<form onSubmit={(e) => { e.preventDefault(); saveReminder(); }} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>What needs doing?</Label>
|
||||
<Input value={reminder.title} onChange={(e) => setReminder({ ...reminder, title: e.target.value })}
|
||||
placeholder="Clean gutters, change furnace filter…" autoFocus />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Repeats</Label>
|
||||
<Select value={reminder.recurrence} onValueChange={(v) => setReminder({ ...reminder, recurrence: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ONCE">Just once</SelectItem>
|
||||
<SelectItem value="CUSTOM">Every N days</SelectItem>
|
||||
<SelectItem value="MONTHLY">Every month</SelectItem>
|
||||
<SelectItem value="YEARLY">Every year</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{reminder.recurrence === "CUSTOM" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Every how many days?</Label>
|
||||
<Input type="number" min="1" value={reminder.intervalDays} onChange={(e) => setReminder({ ...reminder, intervalDays: e.target.value })} />
|
||||
</div>
|
||||
)}
|
||||
{reminder.recurrence === "YEARLY" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Which month?</Label>
|
||||
<Select value={reminder.month} onValueChange={(v) => setReminder({ ...reminder, month: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONTH_NAMES.map((n, i) => <SelectItem key={i + 1} value={String(i + 1)}>{n}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label>First due date</Label>
|
||||
<Input type="date" value={reminder.nextDue} onChange={(e) => setReminder({ ...reminder, nextDue: e.target.value })} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setReminder(null)}>Cancel</Button>
|
||||
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save reminder"}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
104
src/components/locations/quick-setup-button.tsx
Normal file
104
src/components/locations/quick-setup-button.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
||||
<Wand2 className="h-4 w-4 mr-1" /> Quick setup
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Quick setup from an outline</DialogTitle>
|
||||
<DialogDescription>
|
||||
Each indent is a sub-location. Re-running is safe — existing places are reused, not duplicated.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Textarea rows={14} value={outline} onChange={(e) => setOutline(e.target.value)} className="font-mono text-xs" />
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={groupGarden} onChange={(e) => setGroupGarden(e.target.checked)} className="accent-[hsl(var(--leaf))]" />
|
||||
Move existing garden areas (places that hold plants) under “Yard”
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button onClick={run} disabled={busy || !outline.trim()}>{busy ? "Setting up…" : "Create"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export const taskCreateInput = z.object({
|
||||
plantId: z.string().optional(),
|
||||
itemId: z.string().optional(),
|
||||
animalId: z.string().optional(),
|
||||
locationId: z.string().optional(),
|
||||
recurrence: z.enum(["ONCE", "CUSTOM", "MONTHLY", "YEARLY"]).default("ONCE"),
|
||||
intervalDays: z.number().int().positive().optional(),
|
||||
month: z.number().int().min(1).max(12).optional(),
|
||||
|
||||
@@ -8,6 +8,14 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.18.0",
|
||||
date: "2026-06-16",
|
||||
changes: [
|
||||
"Locations \"Quick setup\" — on the Locations page, click Quick setup and it's pre-filled with the whole house layout (rooms, basement, garage, sheds, yard). Click Create and the tree is built; it can also sweep all your existing garden areas under a single \"Yard\".",
|
||||
"Reminders can now be attached to a place — e.g. \"clean gutters every fall\" on the house exterior, or \"change the furnace filter.\" Add one from a location's menu; it shows in Reminders with the spot.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.17.0",
|
||||
date: "2026-06-16",
|
||||
|
||||
35
src/lib/locations/outline.test.ts
Normal file
35
src/lib/locations/outline.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseLocationOutline } from "./outline";
|
||||
|
||||
describe("parseLocationOutline", () => {
|
||||
it("derives depth from indentation", () => {
|
||||
const nodes = parseLocationOutline("House\n Main floor\n Kitchen\n Pantry\n Garage\nYard");
|
||||
expect(nodes).toEqual([
|
||||
{ name: "House", depth: 0 },
|
||||
{ name: "Main floor", depth: 1 },
|
||||
{ name: "Kitchen", depth: 2 },
|
||||
{ name: "Pantry", depth: 3 },
|
||||
{ name: "Garage", depth: 1 },
|
||||
{ name: "Yard", depth: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats tabs as two spaces and ignores blank lines", () => {
|
||||
const nodes = parseLocationOutline("A\n\n\tB\n\t\tC");
|
||||
expect(nodes.map((n) => `${n.depth}:${n.name}`)).toEqual(["0:A", "1:B", "2:C"]);
|
||||
});
|
||||
|
||||
it("strips leading bullets/dashes", () => {
|
||||
const nodes = parseLocationOutline("- Shed\n * Brown\n - Green");
|
||||
expect(nodes).toEqual([
|
||||
{ name: "Shed", depth: 0 },
|
||||
{ name: "Brown", depth: 1 },
|
||||
{ name: "Green", depth: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles inconsistent indent widths by relative nesting", () => {
|
||||
const nodes = parseLocationOutline("A\n B\n C\n D");
|
||||
expect(nodes.map((n) => `${n.depth}:${n.name}`)).toEqual(["0:A", "1:B", "2:C", "1:D"]);
|
||||
});
|
||||
});
|
||||
21
src/lib/locations/outline.ts
Normal file
21
src/lib/locations/outline.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Pure parser: an indented text outline → a flat, ordered list of nodes with a
|
||||
// depth, from which a nested Location tree is built. Indentation (spaces, tabs→2)
|
||||
// sets depth; leading bullets/dashes are tolerated. Used by the "Quick setup".
|
||||
|
||||
export type OutlineNode = { name: string; depth: number };
|
||||
|
||||
export function parseLocationOutline(text: string): OutlineNode[] {
|
||||
const out: OutlineNode[] = [];
|
||||
const indents: number[] = []; // indent width of each ancestor on the stack
|
||||
for (const rawLine of text.split("\n")) {
|
||||
if (!rawLine.trim()) continue;
|
||||
const line = rawLine.replace(/\t/g, " ");
|
||||
const indent = line.length - line.replace(/^\s+/, "").length;
|
||||
const name = line.trim().replace(/^[-*•]\s+/, "").trim();
|
||||
if (!name) continue;
|
||||
while (indents.length && indents[indents.length - 1] >= indent) indents.pop();
|
||||
out.push({ name, depth: indents.length });
|
||||
indents.push(indent);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
68
src/lib/services/location-setup.ts
Normal file
68
src/lib/services/location-setup.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// Bulk Location setup — create a nested tree from a pasted outline, and
|
||||
// (optionally) sweep existing top-level garden areas under a parent. DB-touching;
|
||||
// parsing is pure (src/lib/locations/outline.ts).
|
||||
import { db } from "@/lib/db";
|
||||
import type { AuthContext } from "@/lib/api/authenticate";
|
||||
import { recomputeAllPaths } from "@/lib/locations/db";
|
||||
import { parseLocationOutline } from "@/lib/locations/outline";
|
||||
|
||||
export type SetupSummary = { created: number; moved: number };
|
||||
|
||||
export async function bulkSetupLocations(
|
||||
ctx: AuthContext,
|
||||
opts: { outline: string; groupGardenUnder?: string },
|
||||
): Promise<SetupSummary> {
|
||||
const nodes = parseLocationOutline(opts.outline);
|
||||
|
||||
// find-or-create by (name, parentId), case-insensitive.
|
||||
const rows = await db.location.findMany({ select: { id: true, name: true, parentId: true } });
|
||||
const cache = new Map<string, string>();
|
||||
for (const r of rows) cache.set(`${r.parentId ?? ""}:${r.name.toLowerCase()}`, r.id);
|
||||
|
||||
let created = 0;
|
||||
const stack: { depth: number; id: string }[] = []; // ancestor chain
|
||||
for (const node of nodes) {
|
||||
while (stack.length && stack[stack.length - 1].depth >= node.depth) stack.pop();
|
||||
const parentId = stack.length ? stack[stack.length - 1].id : null;
|
||||
const key = `${parentId ?? ""}:${node.name.toLowerCase()}`;
|
||||
let id = cache.get(key);
|
||||
if (!id) {
|
||||
const loc = await db.location.create({ data: { name: node.name, kind: "OTHER", parentId } });
|
||||
id = loc.id;
|
||||
cache.set(key, id);
|
||||
created++;
|
||||
}
|
||||
stack.push({ depth: node.depth, id });
|
||||
}
|
||||
|
||||
// Sweep existing top-level places that hold plants under the named parent.
|
||||
let moved = 0;
|
||||
if (opts.groupGardenUnder) {
|
||||
const parent = await db.location.findFirst({
|
||||
where: { active: true, parentId: null, name: { equals: opts.groupGardenUnder, mode: "insensitive" } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (parent) {
|
||||
const gardenAreas = await db.location.findMany({
|
||||
where: {
|
||||
active: true,
|
||||
parentId: null,
|
||||
id: { not: parent.id },
|
||||
plants: { some: { active: true } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
for (const a of gardenAreas) {
|
||||
await db.location.update({ where: { id: a.id }, data: { parentId: parent.id } });
|
||||
}
|
||||
moved = gardenAreas.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (created > 0 || moved > 0) await recomputeAllPaths();
|
||||
await db.auditEvent.create({
|
||||
data: { action: "location.bulk_setup", actorId: ctx.userId, payload: { created, moved, via: ctx.via } },
|
||||
});
|
||||
|
||||
return { created, moved };
|
||||
}
|
||||
@@ -7,7 +7,12 @@ export function listTasks(_ctx: AuthContext) {
|
||||
return db.task.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { nextDue: "asc" },
|
||||
include: { plant: { select: { id: true, commonName: true } } },
|
||||
include: {
|
||||
plant: { select: { id: true, commonName: true } },
|
||||
item: { select: { id: true, name: true } },
|
||||
animal: { select: { id: true, name: true } },
|
||||
location: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +25,7 @@ export async function createTask(ctx: AuthContext, input: TaskCreateInput) {
|
||||
plantId: input.plantId || null,
|
||||
itemId: input.itemId || null,
|
||||
animalId: input.animalId || null,
|
||||
locationId: input.locationId || null,
|
||||
recurrence: input.recurrence,
|
||||
intervalDays: input.intervalDays ?? null,
|
||||
month: input.month ?? null,
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
||||
export const APP_VERSION = "0.17.0";
|
||||
export const APP_VERSION = "0.18.0";
|
||||
|
||||
Reference in New Issue
Block a user