feat(products): improve replenishment-aware shopping suggestions

Replace product weight and repurchase intent fields with per-package remaining levels and inventory-first restock signals. Enrich shopping suggestions with usage-aware replenishment scoring so the frontend and LLM can prioritize real gaps and near-empty staples more reliably.
This commit is contained in:
Piotr Oleszczyk 2026-03-09 13:37:40 +01:00
parent bb5d402c15
commit d91d06455b
18 changed files with 587 additions and 210 deletions

View file

@ -95,8 +95,11 @@
"inventory_openedDate": "Opened date",
"inventory_finishedDate": "Finished date",
"inventory_expiryDate": "Expiry date",
"inventory_currentWeight": "Current weight (g)",
"inventory_lastWeighed": "Last weighed",
"inventory_remainingLevel": "Remaining product level",
"inventory_remainingHigh": "high",
"inventory_remainingMedium": "medium",
"inventory_remainingLow": "low",
"inventory_remainingNearlyEmpty": "nearly empty",
"inventory_notes": "Notes",
"inventory_badgeOpen": "Open",
"inventory_badgeSealed": "Sealed",
@ -104,8 +107,6 @@
"inventory_exp": "Exp:",
"inventory_opened": "Opened:",
"inventory_finished": "Finished:",
"inventory_remaining": "g remaining",
"inventory_weighed": "Weighed:",
"inventory_confirmDelete": "Delete this package?",
"routines_title": "Routines",
@ -514,7 +515,6 @@
"productForm_isTool": "Is tool (e.g. dermaroller)",
"productForm_needleLengthMm": "Needle length (mm, tools only)",
"productForm_personalNotes": "Personal notes",
"productForm_repurchaseIntent": "Repurchase intent",
"productForm_toleranceNotes": "Tolerance notes",
"productForm_toleranceNotesPlaceholder": "e.g. Causes mild stinging, fine after 2 weeks",

View file

@ -97,8 +97,11 @@
"inventory_openedDate": "Data otwarcia",
"inventory_finishedDate": "Data skończenia",
"inventory_expiryDate": "Data ważności",
"inventory_currentWeight": "Aktualna waga (g)",
"inventory_lastWeighed": "Ostatnie ważenie",
"inventory_remainingLevel": "Poziom pozostałego produktu:",
"inventory_remainingHigh": "dużo",
"inventory_remainingMedium": "średnio",
"inventory_remainingLow": "mało",
"inventory_remainingNearlyEmpty": "prawie puste",
"inventory_notes": "Notatki",
"inventory_badgeOpen": "Otwarte",
"inventory_badgeSealed": "Zamknięte",
@ -106,8 +109,6 @@
"inventory_exp": "Wazność:",
"inventory_opened": "Otwarto:",
"inventory_finished": "Skończono:",
"inventory_remaining": "g pozostało",
"inventory_weighed": "Ważono:",
"inventory_confirmDelete": "Usunąć to opakowanie?",
"routines_title": "Rutyny",
@ -528,7 +529,6 @@
"productForm_isTool": "To narzędzie (np. dermaroller)",
"productForm_needleLengthMm": "Długość igły (mm, tylko narzędzia)",
"productForm_personalNotes": "Notatki osobiste",
"productForm_repurchaseIntent": "Zamiar ponownego zakupu",
"productForm_toleranceNotes": "Notatki o tolerancji",
"productForm_toleranceNotesPlaceholder": "np. Lekkie pieczenie, ustępuje po 2 tygodniach",

View file

@ -135,8 +135,6 @@ export interface ProductParseResponse {
price_amount?: number;
price_currency?: string;
size_ml?: number;
full_weight_g?: number;
empty_weight_g?: number;
pao_months?: number;
inci?: string[];
actives?: ActiveIngredient[];

View file

@ -165,8 +165,6 @@
let sku = $state(untrack(() => product?.sku ?? ''));
let barcode = $state(untrack(() => product?.barcode ?? ''));
let sizeMl = $state(untrack(() => (product?.size_ml != null ? String(product.size_ml) : '')));
let fullWeightG = $state(untrack(() => (product?.full_weight_g != null ? String(product.full_weight_g) : '')));
let emptyWeightG = $state(untrack(() => (product?.empty_weight_g != null ? String(product.empty_weight_g) : '')));
let paoMonths = $state(untrack(() => (product?.pao_months != null ? String(product.pao_months) : '')));
let phMin = $state(untrack(() => (product?.ph_min != null ? String(product.ph_min) : '')));
let phMax = $state(untrack(() => (product?.ph_max != null ? String(product.ph_max) : '')));
@ -221,8 +219,6 @@
if (r.price_currency) priceCurrency = r.price_currency;
if (r.leave_on != null) leaveOn = String(r.leave_on);
if (r.size_ml != null) sizeMl = String(r.size_ml);
if (r.full_weight_g != null) fullWeightG = String(r.full_weight_g);
if (r.empty_weight_g != null) emptyWeightG = String(r.empty_weight_g);
if (r.pao_months != null) paoMonths = String(r.pao_months);
if (r.ph_min != null) phMin = String(r.ph_min);
if (r.ph_max != null) phMax = String(r.ph_max);
@ -281,9 +277,6 @@
let pregnancySafe = $state(
untrack(() => (product?.pregnancy_safe != null ? String(product.pregnancy_safe) : ''))
);
let personalRepurchaseIntent = $state(
untrack(() => (product?.personal_repurchase_intent != null ? String(product.personal_repurchase_intent) : ''))
);
// context rules tristate
const cr = untrack(() => product?.context_rules);
@ -403,8 +396,6 @@
sku,
barcode,
sizeMl,
fullWeightG,
emptyWeightG,
paoMonths,
phMin,
phMax,
@ -428,7 +419,6 @@
essentialOilsFree,
alcoholDenatFree,
pregnancySafe,
personalRepurchaseIntent,
ctxAfterShaving,
ctxAfterAcids,
ctxAfterRetinoids,
@ -723,8 +713,6 @@
bind:priceAmount
bind:priceCurrency
bind:sizeMl
bind:fullWeightG
bind:emptyWeightG
bind:paoMonths
bind:phMin
bind:phMax
@ -743,10 +731,7 @@
{@const NotesSection = mod.default}
<NotesSection
visible={editSection === 'notes'}
{selectClass}
{textareaClass}
{tristate}
bind:personalRepurchaseIntent
bind:personalToleranceNotes
/>
{/await}

View file

@ -9,8 +9,6 @@
priceAmount = $bindable(''),
priceCurrency = $bindable('PLN'),
sizeMl = $bindable(''),
fullWeightG = $bindable(''),
emptyWeightG = $bindable(''),
paoMonths = $bindable(''),
phMin = $bindable(''),
phMax = $bindable(''),
@ -27,8 +25,6 @@
priceAmount?: string;
priceCurrency?: string;
sizeMl?: string;
fullWeightG?: string;
emptyWeightG?: string;
paoMonths?: string;
phMin?: string;
phMax?: string;
@ -63,16 +59,6 @@
<Input id="size_ml" name="size_ml" type="number" min="0" step="0.1" placeholder={m["productForm_sizePlaceholder"]()} bind:value={sizeMl} />
</div>
<div class="space-y-2">
<Label for="full_weight_g">{m["productForm_fullWeightG"]()}</Label>
<Input id="full_weight_g" name="full_weight_g" type="number" min="0" step="0.1" placeholder={m["productForm_fullWeightPlaceholder"]()} bind:value={fullWeightG} />
</div>
<div class="space-y-2">
<Label for="empty_weight_g">{m["productForm_emptyWeightG"]()}</Label>
<Input id="empty_weight_g" name="empty_weight_g" type="number" min="0" step="0.1" placeholder={m["productForm_emptyWeightPlaceholder"]()} bind:value={emptyWeightG} />
</div>
<div class="space-y-2">
<Label for="pao_months">{m["productForm_paoMonths"]()}</Label>
<Input id="pao_months" name="pao_months" type="number" min="1" max="60" placeholder={m["productForm_paoPlaceholder"]()} bind:value={paoMonths} />

View file

@ -3,21 +3,13 @@
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Label } from '$lib/components/ui/label';
type TriOption = { value: string; label: string };
let {
visible = false,
selectClass,
textareaClass,
tristate,
personalRepurchaseIntent = $bindable(''),
personalToleranceNotes = $bindable('')
}: {
visible?: boolean;
selectClass: string;
textareaClass: string;
tristate: TriOption[];
personalRepurchaseIntent?: string;
personalToleranceNotes?: string;
} = $props();
</script>
@ -25,15 +17,6 @@
<Card class={visible ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_personalNotes"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="repurchase_intent_select">{m["productForm_repurchaseIntent"]()}</Label>
<select id="repurchase_intent_select" name="personal_repurchase_intent" class={selectClass} bind:value={personalRepurchaseIntent}>
{#each tristate as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="personal_tolerance_notes">{m["productForm_toleranceNotes"]()}</Label>
<textarea

View file

@ -43,6 +43,7 @@ export type OverallSkinState = "excellent" | "good" | "fair" | "poor";
export type PartOfDay = "am" | "pm";
export type PriceTier = "budget" | "mid" | "premium" | "luxury";
export type PriceTierSource = "category" | "fallback" | "insufficient_data";
export type RemainingLevel = "high" | "medium" | "low" | "nearly_empty";
export type ProductCategory =
| "cleanser"
| "toner"
@ -129,8 +130,7 @@ export interface ProductInventory {
opened_at?: string;
finished_at?: string;
expiry_date?: string;
current_weight_g?: number;
last_weighed_at?: string;
remaining_level?: RemainingLevel;
notes?: string;
created_at: string;
product?: Product;
@ -155,8 +155,6 @@ export interface Product {
price_per_use_pln?: number;
price_tier_source?: PriceTierSource;
size_ml?: number;
full_weight_g?: number;
empty_weight_g?: number;
pao_months?: number;
inci: string[];
actives?: ActiveIngredient[];
@ -176,7 +174,6 @@ export interface Product {
is_tool: boolean;
needle_length_mm?: number;
personal_tolerance_notes?: string;
personal_repurchase_intent?: boolean;
created_at: string;
updated_at: string;
inventory: ProductInventory[];

View file

@ -127,8 +127,6 @@ export const actions: Actions = {
// Optional numbers
body.size_ml = parseOptionalFloat(form.get('size_ml') as string | null) ?? null;
body.full_weight_g = parseOptionalFloat(form.get('full_weight_g') as string | null) ?? null;
body.empty_weight_g = parseOptionalFloat(form.get('empty_weight_g') as string | null) ?? null;
body.pao_months = parseOptionalInt(form.get('pao_months') as string | null) ?? null;
body.ph_min = parseOptionalFloat(form.get('ph_min') as string | null) ?? null;
body.ph_max = parseOptionalFloat(form.get('ph_max') as string | null) ?? null;
@ -144,7 +142,7 @@ export const actions: Actions = {
body.is_tool = form.get('is_tool') === 'true';
// Nullable booleans
for (const field of ['fragrance_free', 'essential_oils_free', 'alcohol_denat_free', 'pregnancy_safe', 'personal_repurchase_intent']) {
for (const field of ['fragrance_free', 'essential_oils_free', 'alcohol_denat_free', 'pregnancy_safe']) {
body[field] = parseTristate(form.get(field) as string | null) ?? null;
}
@ -192,10 +190,8 @@ export const actions: Actions = {
if (finishedAt) body.finished_at = finishedAt;
const expiry = form.get('expiry_date');
if (expiry) body.expiry_date = expiry;
const weight = form.get('current_weight_g');
if (weight) body.current_weight_g = Number(weight);
const lastWeighed = form.get('last_weighed_at');
if (lastWeighed) body.last_weighed_at = lastWeighed;
const remainingLevel = form.get('remaining_level');
if (remainingLevel) body.remaining_level = remainingLevel;
const notes = form.get('notes');
if (notes) body.notes = notes;
try {
@ -219,10 +215,8 @@ export const actions: Actions = {
body.finished_at = finishedAt || null;
const expiry = form.get('expiry_date');
body.expiry_date = expiry || null;
const weight = form.get('current_weight_g');
body.current_weight_g = weight ? Number(weight) : null;
const lastWeighed = form.get('last_weighed_at');
body.last_weighed_at = lastWeighed || null;
const remainingLevel = form.get('remaining_level');
body.remaining_level = remainingLevel || null;
const notes = form.get('notes');
body.notes = notes || null;
try {

View file

@ -2,6 +2,7 @@
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
@ -15,8 +16,16 @@
let { data, form }: { data: PageData; form: ActionData } = $props();
let { product } = $derived(data);
const remainingLevels = ['high', 'medium', 'low', 'nearly_empty'] as const;
const remainingLevelOptions = remainingLevels.map((level) => ({
value: level,
label: remainingLevelLabel(level)
}));
let showInventoryForm = $state(false);
let addInventoryOpened = $state(false);
let editingInventoryId = $state<string | null>(null);
let editingInventoryOpened = $state<Record<string, boolean>>({});
let activeTab = $state<'inventory' | 'edit'>('edit');
let isEditDirty = $state(false);
let editSaveVersion = $state(0);
@ -60,6 +69,14 @@
if (value === 'luxury') return m['productForm_priceLuxury']();
return value;
}
function remainingLevelLabel(value?: string): string {
if (value === 'high') return m['inventory_remainingHigh']();
if (value === 'medium') return m['inventory_remainingMedium']();
if (value === 'low') return m['inventory_remainingLow']();
if (value === 'nearly_empty') return m['inventory_remainingNearlyEmpty']();
return '';
}
</script>
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
@ -147,41 +164,43 @@
<CardHeader class="pb-2">
<CardTitle class="text-base">{m["inventory_addPackage"]()}</CardTitle>
</CardHeader>
<CardContent>
<form method="POST" action="?/addInventory" use:enhance class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="sm:col-span-2 flex items-center gap-2">
<input type="checkbox" id="add_is_opened" name="is_opened" value="true" class="h-4 w-4" />
<Label for="add_is_opened">{m["inventory_alreadyOpened"]()}</Label>
</div>
<div class="space-y-1">
<Label for="add_opened_at">{m["inventory_openedDate"]()}</Label>
<Input id="add_opened_at" name="opened_at" type="date" />
</div>
<div class="space-y-1">
<Label for="add_finished_at">{m["inventory_finishedDate"]()}</Label>
<Input id="add_finished_at" name="finished_at" type="date" />
</div>
<div class="space-y-1">
<Label for="add_expiry_date">{m["inventory_expiryDate"]()}</Label>
<Input id="add_expiry_date" name="expiry_date" type="date" />
</div>
<div class="space-y-1">
<Label for="add_current_weight_g">{m["inventory_currentWeight"]()}</Label>
<Input id="add_current_weight_g" name="current_weight_g" type="number" min="0" />
</div>
<div class="space-y-1">
<Label for="add_last_weighed_at">{m["inventory_lastWeighed"]()}</Label>
<Input id="add_last_weighed_at" name="last_weighed_at" type="date" />
</div>
<div class="space-y-1">
<Label for="add_notes">{m.inventory_notes()}</Label>
<Input id="add_notes" name="notes" />
</div>
<div class="flex items-end">
<Button type="submit" size="sm">{m.common_add()}</Button>
</div>
</form>
</CardContent>
<CardContent>
<form method="POST" action="?/addInventory" use:enhance class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="sm:col-span-2 flex items-center gap-2">
<input type="checkbox" id="add_is_opened" name="is_opened" value="true" class="h-4 w-4" bind:checked={addInventoryOpened} />
<Label for="add_is_opened">{m["inventory_alreadyOpened"]()}</Label>
</div>
<div class="space-y-1">
<Label for="add_opened_at">{m["inventory_openedDate"]()}</Label>
<Input id="add_opened_at" name="opened_at" type="date" />
</div>
<div class="space-y-1">
<Label for="add_finished_at">{m["inventory_finishedDate"]()}</Label>
<Input id="add_finished_at" name="finished_at" type="date" />
</div>
<div class="space-y-1">
<Label for="add_expiry_date">{m["inventory_expiryDate"]()}</Label>
<Input id="add_expiry_date" name="expiry_date" type="date" />
</div>
{#if addInventoryOpened}
<SimpleSelect
id="add_remaining_level"
name="remaining_level"
label={m["inventory_remainingLevel"]()}
options={remainingLevelOptions}
placeholder={m.common_unknown()}
/>
{/if}
<div class="space-y-1">
<Label for="add_notes">{m.inventory_notes()}</Label>
<Input id="add_notes" name="notes" />
</div>
<div class="flex items-end">
<Button type="submit" size="sm">{m.common_add()}</Button>
</div>
</form>
</CardContent>
</Card>
{/if}
@ -206,11 +225,8 @@
{#if pkg.finished_at}
<span class="text-muted-foreground">{m.inventory_finished()} {pkg.finished_at.slice(0, 10)}</span>
{/if}
{#if pkg.current_weight_g}
<span class="text-muted-foreground">{pkg.current_weight_g}g {m.inventory_remaining()}</span>
{/if}
{#if pkg.last_weighed_at}
<span class="text-muted-foreground">{m.inventory_weighed()} {pkg.last_weighed_at.slice(0, 10)}</span>
{#if pkg.is_opened && pkg.remaining_level}
<span class="text-muted-foreground">{m["inventory_remainingLevel"]()} {remainingLevelLabel(pkg.remaining_level)}</span>
{/if}
{#if pkg.notes}
<span class="text-muted-foreground">{pkg.notes}</span>
@ -220,7 +236,14 @@
<Button
variant="ghost"
size="sm"
onclick={() => (editingInventoryId = editingInventoryId === pkg.id ? null : pkg.id)}
onclick={() => {
if (editingInventoryId === pkg.id) {
editingInventoryId = null;
} else {
editingInventoryId = pkg.id;
editingInventoryOpened = { ...editingInventoryOpened, [pkg.id]: pkg.is_opened };
}
}}
>
{editingInventoryId === pkg.id ? m.common_cancel() : m.common_edit()}
</Button>
@ -256,7 +279,11 @@
id="edit_is_opened_{pkg.id}"
name="is_opened"
value="true"
checked={pkg.is_opened}
checked={editingInventoryOpened[pkg.id] ?? pkg.is_opened}
onchange={(e) => {
const target = e.currentTarget as HTMLInputElement;
editingInventoryOpened = { ...editingInventoryOpened, [pkg.id]: target.checked };
}}
class="h-4 w-4"
/>
<Label for="edit_is_opened_{pkg.id}">{m["inventory_alreadyOpened"]()}</Label>
@ -273,14 +300,16 @@
<Label for="edit_expiry_{pkg.id}">{m["inventory_expiryDate"]()}</Label>
<Input id="edit_expiry_{pkg.id}" name="expiry_date" type="date" value={pkg.expiry_date?.slice(0, 10) ?? ''} />
</div>
<div class="space-y-1">
<Label for="edit_weight_{pkg.id}">{m["inventory_currentWeight"]()}</Label>
<Input id="edit_weight_{pkg.id}" name="current_weight_g" type="number" min="0" value={pkg.current_weight_g ?? ''} />
</div>
<div class="space-y-1">
<Label for="edit_last_weighed_{pkg.id}">{m["inventory_lastWeighed"]()}</Label>
<Input id="edit_last_weighed_{pkg.id}" name="last_weighed_at" type="date" value={pkg.last_weighed_at?.slice(0, 10) ?? ''} />
</div>
{#if editingInventoryOpened[pkg.id] ?? pkg.is_opened}
<SimpleSelect
id={`edit_remaining_level_${pkg.id}`}
name="remaining_level"
label={m["inventory_remainingLevel"]()}
options={remainingLevelOptions}
placeholder={m.common_unknown()}
value={pkg.remaining_level ?? ''}
/>
{/if}
<div class="space-y-1">
<Label for="edit_notes_{pkg.id}">{m.inventory_notes()}</Label>
<Input id="edit_notes_{pkg.id}" name="notes" value={pkg.notes ?? ''} />

View file

@ -118,12 +118,6 @@ export const actions: Actions = {
const size_ml = parseOptionalFloat(form.get('size_ml') as string | null);
if (size_ml !== undefined) payload.size_ml = size_ml;
const full_weight_g = parseOptionalFloat(form.get('full_weight_g') as string | null);
if (full_weight_g !== undefined) payload.full_weight_g = full_weight_g;
const empty_weight_g = parseOptionalFloat(form.get('empty_weight_g') as string | null);
if (empty_weight_g !== undefined) payload.empty_weight_g = empty_weight_g;
const pao_months = parseOptionalInt(form.get('pao_months') as string | null);
if (pao_months !== undefined) payload.pao_months = pao_months;
@ -149,7 +143,7 @@ export const actions: Actions = {
payload.is_tool = form.get('is_tool') === 'true';
// Nullable booleans
for (const field of ['fragrance_free', 'essential_oils_free', 'alcohol_denat_free', 'pregnancy_safe', 'personal_repurchase_intent']) {
for (const field of ['fragrance_free', 'essential_oils_free', 'alcohol_denat_free', 'pregnancy_safe']) {
const v = parseTristate(form.get(field) as string | null);
if (v !== undefined) payload[field] = v;
}