Filter unit dropdown by product allowed-unit categories

Adds a UnitCategoryFlags column to Product, FamilyProduct, and
FamilyProductOverride so each product can advertise which unit categories
it is typically packaged by (e.g. flour: Weight | Volume). The product
endpoints round-trip the flag, search projects the effective value with
the override applied, and the frontend QuantityInput soft-filters its
dropdown by the selected product's flag, with a "show all units" escape
hatch for ad-hoc overrides.

No backend rejection on a unit outside the allowed set — the flag is
purely a hint. Default value is None (no filter), so existing data is
unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Josh Rogers
2026-05-13 22:17:43 -05:00
parent fb1bc2b7e1
commit fd6b0accc8
14 changed files with 1411 additions and 36 deletions
@@ -1,4 +1,14 @@
<script lang="ts" module>
// Bitfield matching backend UnitCategoryFlags. Stored as an int on the wire.
export const UnitCategoryFlag = {
None: 0,
Count: 1,
Weight: 2,
Volume: 4,
Packaging: 8,
} as const;
export type UnitCategoryFlag = typeof UnitCategoryFlag[keyof typeof UnitCategoryFlag];
export interface ProductSuggestion {
id: number;
kind: 'Global' | 'Family';
@@ -6,6 +16,7 @@
brand: string | null;
notes: string | null;
isOverridden: boolean;
allowedUnitCategories: number;
}
</script>
+57 -27
View File
@@ -7,17 +7,20 @@
</script>
<script lang="ts">
import { units } from '$lib/units.svelte';
import { units, type UnitCategory } from '$lib/units.svelte';
interface Props {
value: QuantityValue;
ariaLabel?: string;
// When non-zero, soft-filters the unit dropdown to units whose category
// is one of the flagged categories. Users can still expand to all units
// via the "show all" link. 0 (None) = no filter applied.
allowedUnitCategories?: number;
}
let { value = $bindable(), ariaLabel = 'Quantity' }: Props = $props();
let { value = $bindable(), ariaLabel = 'Quantity', allowedUnitCategories = 0 }: Props = $props();
let showAll = $state(false);
// The unit dropdown carries a composite id "kind:id" so we can route to
// the right FK column on the server. "" = no unit selected.
const composite = $derived.by(() => {
if (value.unitOfMeasureId !== null) return `Global:${value.unitOfMeasureId}`;
if (value.familyUnitOfMeasureId !== null) return `Family:${value.familyUnitOfMeasureId}`;
@@ -47,32 +50,59 @@
value.quantity = raw === '' ? null : Number(raw);
}
const categoryBit: Record<UnitCategory, number> = {
Count: 1, Weight: 2, Volume: 4, Packaging: 8,
};
const sorted = $derived(
[...units.all].sort((a, b) => a.sortOrder - b.sortOrder || a.singularName.localeCompare(b.singularName))
);
// If the filter mask is non-zero and showAll isn't on, keep only matching
// units — plus the currently-selected one so the dropdown can render it
// even if the product was changed to one with a tighter filter.
const visible = $derived.by(() => {
const mask = allowedUnitCategories;
if (mask === 0 || showAll) return sorted;
const selectedId = value.unitOfMeasureId ?? value.familyUnitOfMeasureId;
const selectedKind = value.unitOfMeasureId !== null ? 'Global' : 'Family';
return sorted.filter((u) => {
if ((categoryBit[u.category] & mask) !== 0) return true;
return selectedId !== null && u.id === selectedId && u.kind === selectedKind;
});
});
const filterIsActive = $derived(allowedUnitCategories !== 0 && !showAll && visible.length !== sorted.length);
</script>
<div class="flex gap-1">
<input
type="number"
step="0.01"
min="0"
inputmode="decimal"
value={value.quantity ?? ''}
oninput={onQuantityInput}
placeholder="Qty"
aria-label={ariaLabel}
class="w-20 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
/>
<select
value={composite}
onchange={onUnitChange}
aria-label="Unit"
class="w-24 rounded-lg border border-gray-300 px-1 py-2 text-sm focus:border-primary focus:outline-none"
>
<option value="">unit</option>
{#each sorted as unit}
<option value="{unit.kind}:{unit.id}">{unit.abbreviation}</option>
{/each}
</select>
<div class="flex flex-col gap-0.5">
<div class="flex gap-1">
<input
type="number"
step="0.01"
min="0"
inputmode="decimal"
value={value.quantity ?? ''}
oninput={onQuantityInput}
placeholder="Qty"
aria-label={ariaLabel}
class="w-20 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
/>
<select
value={composite}
onchange={onUnitChange}
aria-label="Unit"
class="w-24 rounded-lg border border-gray-300 px-1 py-2 text-sm focus:border-primary focus:outline-none"
>
<option value="">unit</option>
{#each visible as unit}
<option value="{unit.kind}:{unit.id}">{unit.abbreviation}</option>
{/each}
</select>
</div>
{#if filterIsActive}
<button type="button" onclick={() => (showAll = true)} class="text-[10px] text-gray-400 hover:text-primary">
show all units
</button>
{/if}
</div>
@@ -53,6 +53,7 @@
let newItemQuantity = $state<QuantityValue>({ quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null });
let newItemIsApproximate = $state(false);
let newItemQuantityNote = $state('');
let newItemAllowedUnitCategories = $state(0);
let loading = $state(true);
let connection: HubConnection | null = null;
@@ -190,6 +191,7 @@
newItemQuantity = { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null };
newItemIsApproximate = false;
newItemQuantityNote = '';
newItemAllowedUnitCategories = 0;
}
function toggleApproximateNewItem() {
@@ -205,12 +207,15 @@
if (product === null) {
newItemProductId = null;
newItemFamilyProductId = null;
newItemAllowedUnitCategories = 0;
} else if (product.kind === 'Global') {
newItemProductId = product.id;
newItemFamilyProductId = null;
newItemAllowedUnitCategories = product.allowedUnitCategories;
} else {
newItemProductId = null;
newItemFamilyProductId = product.id;
newItemAllowedUnitCategories = product.allowedUnitCategories;
}
}
@@ -278,7 +283,10 @@
class="w-36 rounded-lg border border-gray-300 px-2 py-2.5 text-sm focus:border-primary focus:outline-none"
/>
{:else}
<QuantityInput bind:value={newItemQuantity} />
<QuantityInput
bind:value={newItemQuantity}
allowedUnitCategories={newItemAllowedUnitCategories}
/>
{/if}
<div class="min-w-0 flex-1">
<ProductTypeahead
@@ -11,6 +11,7 @@
quantityNote: string;
productId: number | null;
familyProductId: number | null;
allowedUnitCategories: number;
}
let title = $state('');
@@ -28,6 +29,7 @@
quantityNote: '',
productId: null,
familyProductId: null,
allowedUnitCategories: 0,
};
}
@@ -54,12 +56,15 @@
if (product === null) {
next.productId = null;
next.familyProductId = null;
next.allowedUnitCategories = 0;
} else if (product.kind === 'Global') {
next.productId = product.id;
next.familyProductId = null;
next.allowedUnitCategories = product.allowedUnitCategories;
} else {
next.productId = null;
next.familyProductId = product.id;
next.allowedUnitCategories = product.allowedUnitCategories;
}
}
@@ -143,7 +148,10 @@
class="w-44 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
/>
{:else}
<QuantityInput bind:value={ingredient.quantity} />
<QuantityInput
bind:value={ingredient.quantity}
allowedUnitCategories={ingredient.allowedUnitCategories}
/>
{/if}
<div class="flex-1">
<ProductTypeahead