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[]
|
plants Plant[]
|
||||||
items Item[]
|
items Item[]
|
||||||
animals Animal[]
|
animals Animal[]
|
||||||
|
tasks Task[]
|
||||||
|
|
||||||
@@index([parentId])
|
@@index([parentId])
|
||||||
@@index([kind])
|
@@index([kind])
|
||||||
@@ -242,6 +243,8 @@ model Task {
|
|||||||
item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull)
|
item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull)
|
||||||
animalId String?
|
animalId String?
|
||||||
animal Animal? @relation(fields: [animalId], references: [id], onDelete: SetNull)
|
animal Animal? @relation(fields: [animalId], references: [id], onDelete: SetNull)
|
||||||
|
locationId String?
|
||||||
|
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||||
recurrence RecurrenceType @default(YEARLY)
|
recurrence RecurrenceType @default(YEARLY)
|
||||||
intervalDays Int? // for CUSTOM: every N days
|
intervalDays Int? // for CUSTOM: every N days
|
||||||
month Int? // 1–12: for YEARLY, which month
|
month Int? // 1–12: for YEARLY, which month
|
||||||
@@ -256,6 +259,7 @@ model Task {
|
|||||||
@@index([plantId])
|
@@index([plantId])
|
||||||
@@index([itemId])
|
@@index([itemId])
|
||||||
@@index([animalId])
|
@@index([animalId])
|
||||||
|
@@index([locationId])
|
||||||
@@index([active])
|
@@index([active])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ export default async function TasksPage() {
|
|||||||
const tasks = await db.task.findMany({
|
const tasks = await db.task.findMany({
|
||||||
where: { active: true },
|
where: { active: true },
|
||||||
orderBy: { nextDue: "asc" },
|
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));
|
const overdue = tasks.filter((t) => isOverdue(t.nextDue));
|
||||||
@@ -104,6 +109,9 @@ function Section({ title, icon: Icon, iconClass, children }: {
|
|||||||
function TaskCard({ task, overdue = false }: {
|
function TaskCard({ task, overdue = false }: {
|
||||||
task: Awaited<ReturnType<typeof db.task.findMany>>[0] & {
|
task: Awaited<ReturnType<typeof db.task.findMany>>[0] & {
|
||||||
plant: { id: string; commonName: string; variety: string | null } | null;
|
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;
|
overdue?: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -124,10 +132,23 @@ function TaskCard({ task, overdue = false }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.plant && (
|
{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}` : ""}
|
{task.plant.commonName}{task.plant.variety ? ` · ${task.plant.variety}` : ""}
|
||||||
</Link>
|
</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 && (
|
{task.notes && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">{task.notes}</p>
|
<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,
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
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 { buildTree, flattenForSelect } from "@/lib/locations/tree";
|
||||||
|
import { MONTH_NAMES } from "@/lib/tasks/schedule";
|
||||||
|
import { QuickSetupButton } from "@/components/locations/quick-setup-button";
|
||||||
|
|
||||||
type Loc = {
|
type Loc = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -38,6 +40,10 @@ const NONE = "__none__";
|
|||||||
|
|
||||||
export function LocationManager({ locations }: { locations: Loc[] }) {
|
export function LocationManager({ locations }: { locations: Loc[] }) {
|
||||||
const [dialog, setDialog] = useState<null | { mode: "add" | "edit"; id?: string; name: string; kind: string; parentId: string }>(null);
|
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 [busy, setBusy] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -84,6 +90,28 @@ export function LocationManager({ locations }: { locations: Loc[] }) {
|
|||||||
router.refresh();
|
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).
|
// Valid parents for the dialog (exclude self when editing — server guards descendants).
|
||||||
const parentChoices = flat.filter((n) => !(dialog?.mode === "edit" && n.id === dialog.id));
|
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">
|
<p className="text-sm text-muted-foreground">
|
||||||
The shared place tree — garden areas, buildings, rooms, and storage. Plants and items live here.
|
The shared place tree — garden areas, buildings, rooms, and storage. Plants and items live here.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
{locations.length === 0 ? (
|
{locations.length === 0 ? (
|
||||||
@@ -122,6 +153,9 @@ export function LocationManager({ locations }: { locations: Loc[] }) {
|
|||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => openAdd(n.id)}>Add sub-location</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => openAdd(n.id)}>Add sub-location</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => openEdit(n as unknown as Loc)}>Rename / move</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 />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="text-[hsl(var(--ember))]" onClick={() => remove(n as unknown as Loc)}>Delete</DropdownMenuItem>
|
<DropdownMenuItem className="text-[hsl(var(--ember))]" onClick={() => remove(n as unknown as Loc)}>Delete</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -171,6 +205,60 @@ export function LocationManager({ locations }: { locations: Loc[] }) {
|
|||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</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(),
|
plantId: z.string().optional(),
|
||||||
itemId: z.string().optional(),
|
itemId: z.string().optional(),
|
||||||
animalId: z.string().optional(),
|
animalId: z.string().optional(),
|
||||||
|
locationId: z.string().optional(),
|
||||||
recurrence: z.enum(["ONCE", "CUSTOM", "MONTHLY", "YEARLY"]).default("ONCE"),
|
recurrence: z.enum(["ONCE", "CUSTOM", "MONTHLY", "YEARLY"]).default("ONCE"),
|
||||||
intervalDays: z.number().int().positive().optional(),
|
intervalDays: z.number().int().positive().optional(),
|
||||||
month: z.number().int().min(1).max(12).optional(),
|
month: z.number().int().min(1).max(12).optional(),
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.17.0",
|
||||||
date: "2026-06-16",
|
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({
|
return db.task.findMany({
|
||||||
where: { active: true },
|
where: { active: true },
|
||||||
orderBy: { nextDue: "asc" },
|
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,
|
plantId: input.plantId || null,
|
||||||
itemId: input.itemId || null,
|
itemId: input.itemId || null,
|
||||||
animalId: input.animalId || null,
|
animalId: input.animalId || null,
|
||||||
|
locationId: input.locationId || null,
|
||||||
recurrence: input.recurrence,
|
recurrence: input.recurrence,
|
||||||
intervalDays: input.intervalDays ?? null,
|
intervalDays: input.intervalDays ?? null,
|
||||||
month: input.month ?? null,
|
month: input.month ?? null,
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
// 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