v0.18.1 — Pantry: add custom categories on the fly

Type a new name in the Category or Subcategory dropdown and pick
"Add '...'" at the bottom. Custom categories persist (stored as strings
on the item) and reappear merged into future dropdowns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bonna Moon
2026-06-29 17:48:44 -05:00
parent 608a192877
commit e501679416
4 changed files with 77 additions and 28 deletions

View File

@@ -148,7 +148,22 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
set("name", s.name); set("name", s.name);
} }
const subcategoryOptions = getSubcategoryOptions(form.pantryCategory); // Merge static category list with any custom categories previously saved to the DB.
const catalogCategories: string[] = catalog?.categories ?? [];
const knownCategoryValues = new Set(CATEGORY_OPTIONS.map(o => o.value.toLowerCase()));
const extraCategoryOptions = catalogCategories
.filter(c => !knownCategoryValues.has(c.toLowerCase()))
.map(c => ({ value: c, label: c }));
const allCategoryOptions = [...CATEGORY_OPTIONS, ...extraCategoryOptions];
// Static subcategories for this category + any custom ones from the catalog.
const staticSubcategoryOptions = getSubcategoryOptions(form.pantryCategory);
const catalogSubcategories: { category: string; subcategory: string }[] = catalog?.subcategories ?? [];
const knownSubValues = new Set(staticSubcategoryOptions.map(o => o.value.toLowerCase()));
const extraSubOptions = catalogSubcategories
.filter(cs => cs.category.toLowerCase() === form.pantryCategory.toLowerCase() && !knownSubValues.has(cs.subcategory.toLowerCase()))
.map(cs => ({ value: cs.subcategory, label: cs.subcategory }));
const subcategoryOptions = [...staticSubcategoryOptions, ...extraSubOptions];
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -262,10 +277,11 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
<div className="space-y-1"> <div className="space-y-1">
<Label>Category</Label> <Label>Category</Label>
<Combobox <Combobox
options={CATEGORY_OPTIONS} options={allCategoryOptions}
value={form.pantryCategory} value={form.pantryCategory}
onChange={v => { set("pantryCategory", v); set("pantrySubcategory", ""); }} onChange={v => { set("pantryCategory", v); set("pantrySubcategory", ""); }}
placeholder="Select category…" placeholder="Select or add category…"
allowCustom
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@@ -274,9 +290,10 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
options={subcategoryOptions} options={subcategoryOptions}
value={form.pantrySubcategory} value={form.pantrySubcategory}
onChange={v => set("pantrySubcategory", v)} onChange={v => set("pantrySubcategory", v)}
placeholder="Select subcategory…" placeholder="Select or add…"
disabled={!form.pantryCategory} disabled={!form.pantryCategory}
emptyText="No subcategories" emptyText="Type to add a subcategory"
allowCustom
/> />
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { Check, ChevronsUpDown, Search } from "lucide-react"; import { Check, ChevronsUpDown, Plus, Search } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export type ComboOption = { value: string; label: string }; export type ComboOption = { value: string; label: string };
@@ -14,9 +14,10 @@ type Props = {
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
emptyText?: string; emptyText?: string;
allowCustom?: boolean; // show "Add '[query]'" when no exact match
}; };
export function Combobox({ options, value, onChange, placeholder = "Select…", disabled, emptyText = "No results" }: Props) { export function Combobox({ options, value, onChange, placeholder = "Select…", disabled, emptyText = "No results", allowCustom = false }: Props) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -25,7 +26,14 @@ export function Combobox({ options, value, onChange, placeholder = "Select…",
? options.filter(o => o.label.toLowerCase().includes(query.toLowerCase())) ? options.filter(o => o.label.toLowerCase().includes(query.toLowerCase()))
: options; : options;
// Show "add" option when allowCustom and the query isn't already an exact match
const trimmed = query.trim();
const exactMatch = options.some(o => o.label.toLowerCase() === trimmed.toLowerCase());
const showAdd = allowCustom && trimmed.length > 0 && !exactMatch;
// Display: if value matches a known option use its label, otherwise show value as-is (custom)
const selected = options.find(o => o.value === value); const selected = options.find(o => o.value === value);
const displayValue = selected?.label ?? value;
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -34,6 +42,11 @@ export function Combobox({ options, value, onChange, placeholder = "Select…",
} }
}, [open]); }, [open]);
function pick(val: string) {
onChange(val);
setOpen(false);
}
return ( return (
<Popover.Root open={open} onOpenChange={disabled ? undefined : setOpen}> <Popover.Root open={open} onOpenChange={disabled ? undefined : setOpen}>
<Popover.Trigger asChild> <Popover.Trigger asChild>
@@ -43,10 +56,10 @@ export function Combobox({ options, value, onChange, placeholder = "Select…",
className={cn( className={cn(
"w-full flex items-center justify-between rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring", "w-full flex items-center justify-between rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring",
disabled && "opacity-50 cursor-not-allowed", disabled && "opacity-50 cursor-not-allowed",
!selected && "text-muted-foreground" !displayValue && "text-muted-foreground"
)} )}
> >
<span className="truncate">{selected?.label ?? placeholder}</span> <span className="truncate">{displayValue || placeholder}</span>
<ChevronsUpDown className="h-4 w-4 text-muted-foreground shrink-0 ml-2" /> <ChevronsUpDown className="h-4 w-4 text-muted-foreground shrink-0 ml-2" />
</button> </button>
</Popover.Trigger> </Popover.Trigger>
@@ -75,17 +88,14 @@ export function Combobox({ options, value, onChange, placeholder = "Select…",
{value && ( {value && (
<button <button
type="button" type="button"
className="w-full text-left px-3 py-2 text-sm text-muted-foreground hover:bg-accent flex items-center gap-2" className="w-full text-left px-3 py-2 text-sm text-muted-foreground hover:bg-accent"
onClick={() => { onChange(""); setOpen(false); }} onClick={() => pick("")}
> >
clear clear
</button> </button>
)} )}
{filtered.length === 0 ? ( {filtered.map(opt => (
<p className="px-3 py-4 text-sm text-muted-foreground text-center">{emptyText}</p>
) : (
filtered.map(opt => (
<button <button
key={opt.value} key={opt.value}
type="button" type="button"
@@ -93,12 +103,27 @@ export function Combobox({ options, value, onChange, placeholder = "Select…",
"w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center justify-between gap-2", "w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center justify-between gap-2",
opt.value === value && "font-medium" opt.value === value && "font-medium"
)} )}
onClick={() => { onChange(opt.value); setOpen(false); }} onClick={() => pick(opt.value)}
> >
<span>{opt.label}</span> <span>{opt.label}</span>
{opt.value === value && <Check className="h-3.5 w-3.5 text-primary shrink-0" />} {opt.value === value && <Check className="h-3.5 w-3.5 text-primary shrink-0" />}
</button> </button>
)) ))}
{filtered.length === 0 && !showAdd && (
<p className="px-3 py-4 text-sm text-muted-foreground text-center">{emptyText}</p>
)}
{/* Add custom option */}
{showAdd && (
<button
type="button"
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center gap-2 text-primary border-t mt-1"
onClick={() => pick(trimmed)}
>
<Plus className="h-3.5 w-3.5 shrink-0" />
Add &ldquo;{trimmed}&rdquo;
</button>
)} )}
</div> </div>
</Popover.Content> </Popover.Content>

View File

@@ -8,6 +8,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.18.1",
date: "2026-06-29",
changes: [
"Pantry: you can now add a brand-new category or subcategory on the fly — just type the name into the Category or Subcategory dropdown and pick \"Add [name]\" at the bottom of the list. New categories are remembered in future add-item dialogs.",
],
},
{ {
version: "0.18.0", version: "0.18.0",
date: "2026-06-16", date: "2026-06-16",

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.18.0"; export const APP_VERSION = "0.18.1";