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:
2026-06-16 02:33:52 -05:00
parent 4aacffdf4e
commit ddc0a60a0c
13 changed files with 392 additions and 6 deletions

View File

@@ -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;

View File

@@ -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? // 112: for YEARLY, which month month Int? // 112: 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])
} }

View File

@@ -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>

View 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);
}
}

View File

@@ -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>
); );
} }

View 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 &ldquo;Yard&rdquo;
</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>
</>
);
}

View File

@@ -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(),

View File

@@ -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",

View 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"]);
});
});

View 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;
}

View 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 };
}

View File

@@ -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,

View File

@@ -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";