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

@ -0,0 +1,69 @@
"""replace product weights with inventory remaining level
Revision ID: 9f3a2c1b4d5e
Revises: 7e6f73d1cc95
Create Date: 2026-03-08 12:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "9f3a2c1b4d5e"
down_revision: Union[str, Sequence[str], None] = "7e6f73d1cc95"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
remaining_level_enum = sa.Enum(
"HIGH",
"MEDIUM",
"LOW",
"NEARLY_EMPTY",
name="remaininglevel",
)
remaining_level_enum.create(bind, checkfirst=True)
op.add_column(
"product_inventory",
sa.Column("remaining_level", remaining_level_enum, nullable=True),
)
op.drop_column("product_inventory", "last_weighed_at")
op.drop_column("product_inventory", "current_weight_g")
op.drop_column("products", "personal_repurchase_intent")
op.drop_column("products", "empty_weight_g")
op.drop_column("products", "full_weight_g")
def downgrade() -> None:
bind = op.get_bind()
remaining_level_enum = sa.Enum(
"HIGH",
"MEDIUM",
"LOW",
"NEARLY_EMPTY",
name="remaininglevel",
)
op.add_column(
"products",
sa.Column("personal_repurchase_intent", sa.Boolean(), nullable=True),
)
op.add_column(
"product_inventory",
sa.Column("current_weight_g", sa.Float(), nullable=True),
)
op.add_column(
"product_inventory",
sa.Column("last_weighed_at", sa.Date(), nullable=True),
)
op.add_column("products", sa.Column("full_weight_g", sa.Float(), nullable=True))
op.add_column("products", sa.Column("empty_weight_g", sa.Float(), nullable=True))
op.drop_column("product_inventory", "remaining_level")
remaining_level_enum.drop(bind, checkfirst=True)

View file

@ -1,7 +1,7 @@
import json
import logging
from datetime import date
from typing import Any, Literal, Optional
from typing import Any, Literal, Optional, cast
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Query
@ -48,6 +48,7 @@ from innercontext.models.enums import (
AbsorptionSpeed,
DayTime,
PriceTier,
RemainingLevel,
SkinType,
TextureType,
)
@ -128,8 +129,6 @@ class ProductUpdate(SQLModel):
price_amount: Optional[float] = None
price_currency: Optional[str] = None
size_ml: Optional[float] = None
full_weight_g: Optional[float] = None
empty_weight_g: Optional[float] = None
pao_months: Optional[int] = None
inci: Optional[list[str]] = None
@ -159,7 +158,6 @@ class ProductUpdate(SQLModel):
needle_length_mm: Optional[float] = None
personal_tolerance_notes: Optional[str] = None
personal_repurchase_intent: Optional[bool] = None
class ProductParseRequest(SQLModel):
@ -181,8 +179,6 @@ class ProductParseResponse(SQLModel):
price_amount: Optional[float] = None
price_currency: Optional[str] = None
size_ml: Optional[float] = None
full_weight_g: Optional[float] = None
empty_weight_g: Optional[float] = None
pao_months: Optional[int] = None
inci: Optional[list[str]] = None
actives: Optional[list[ActiveIngredient]] = None
@ -234,8 +230,7 @@ class InventoryCreate(SQLModel):
opened_at: Optional[date] = None
finished_at: Optional[date] = None
expiry_date: Optional[date] = None
current_weight_g: Optional[float] = None
last_weighed_at: Optional[date] = None
remaining_level: Optional[RemainingLevel] = None
notes: Optional[str] = None
@ -244,11 +239,173 @@ class InventoryUpdate(SQLModel):
opened_at: Optional[date] = None
finished_at: Optional[date] = None
expiry_date: Optional[date] = None
current_weight_g: Optional[float] = None
last_weighed_at: Optional[date] = None
remaining_level: Optional[RemainingLevel] = None
notes: Optional[str] = None
def _remaining_level_rank(level: RemainingLevel | str | None) -> int:
if level in (RemainingLevel.NEARLY_EMPTY, "nearly_empty"):
return 0
if level in (RemainingLevel.LOW, "low"):
return 1
if level in (RemainingLevel.MEDIUM, "medium"):
return 2
if level in (RemainingLevel.HIGH, "high"):
return 3
return 99
_STAPLE_CATEGORIES = {"cleanser", "moisturizer", "spf"}
_OCCASIONAL_CATEGORIES = {"exfoliant", "mask", "spot_treatment", "tool"}
def _compute_days_since_last_used(
last_used_on: date | None, reference_date: date
) -> int | None:
if last_used_on is None:
return None
return max((reference_date - last_used_on).days, 0)
def _compute_replenishment_score(
*,
has_stock: bool,
sealed_backup_count: int,
lowest_remaining_level: str | None,
days_since_last_used: int | None,
category: ProductCategory | str,
) -> dict[str, object]:
score = 0
reason_codes: list[str] = []
category_value = _ev(category)
if not has_stock:
score = 90
reason_codes.append("out_of_stock")
elif sealed_backup_count > 0:
score = 10
reason_codes.append("has_sealed_backup")
elif lowest_remaining_level == "nearly_empty":
score = 80
reason_codes.append("nearly_empty_opened")
elif lowest_remaining_level == "low":
score = 60
reason_codes.append("low_opened")
elif lowest_remaining_level == "medium":
score = 25
elif lowest_remaining_level == "high":
score = 5
else:
reason_codes.append("insufficient_remaining_data")
if days_since_last_used is not None:
if days_since_last_used <= 3:
score += 20
reason_codes.append("recently_used")
elif days_since_last_used <= 7:
score += 12
reason_codes.append("recently_used")
elif days_since_last_used <= 14:
score += 6
elif days_since_last_used <= 30:
pass
elif days_since_last_used <= 60:
score -= 10
reason_codes.append("stale_usage")
else:
score -= 20
reason_codes.append("stale_usage")
if category_value in _STAPLE_CATEGORIES:
score += 15
reason_codes.append("staple_category")
elif category_value in _OCCASIONAL_CATEGORIES:
score -= 10
reason_codes.append("occasional_category")
elif category_value == "serum":
score += 5
if sealed_backup_count > 0 and has_stock:
score = min(score, 15)
if (
days_since_last_used is not None
and days_since_last_used > 60
and category_value not in _STAPLE_CATEGORIES
):
score = min(score, 25)
if (
lowest_remaining_level is None
and has_stock
and (days_since_last_used is None or days_since_last_used > 14)
):
score = min(score, 20)
score = max(0, min(score, 100))
if score >= 80:
priority_hint = "high"
elif score >= 50:
priority_hint = "medium"
elif score >= 25:
priority_hint = "low"
else:
priority_hint = "none"
return {
"replenishment_score": score,
"replenishment_priority_hint": priority_hint,
"repurchase_candidate": priority_hint != "none",
"replenishment_reason_codes": reason_codes,
}
def _summarize_inventory_state(entries: list[ProductInventory]) -> dict[str, object]:
active_entries = [entry for entry in entries if entry.finished_at is None]
opened_entries = [entry for entry in active_entries if entry.is_opened]
sealed_entries = [entry for entry in active_entries if not entry.is_opened]
opened_levels = [
_ev(entry.remaining_level)
for entry in opened_entries
if entry.remaining_level is not None
]
opened_levels_sorted = sorted(
opened_levels,
key=_remaining_level_rank,
)
lowest_opened_level = opened_levels_sorted[0] if opened_levels_sorted else None
stock_state = "healthy"
if not active_entries:
stock_state = "out_of_stock"
elif sealed_entries:
stock_state = "healthy"
elif lowest_opened_level == "nearly_empty":
stock_state = "urgent"
elif lowest_opened_level == "low":
stock_state = "low"
elif lowest_opened_level == "medium":
stock_state = "monitor"
replenishment_signal = "none"
if stock_state == "out_of_stock":
replenishment_signal = "out_of_stock"
elif stock_state == "urgent":
replenishment_signal = "urgent"
elif stock_state == "low":
replenishment_signal = "soon"
return {
"has_stock": bool(active_entries),
"active_count": len(active_entries),
"opened_count": len(opened_entries),
"sealed_backup_count": len(sealed_entries),
"opened_levels": opened_levels_sorted,
"lowest_opened_level": lowest_opened_level,
"stock_state": stock_state,
"replenishment_signal": replenishment_signal,
}
# ---------------------------------------------------------------------------
# Shopping suggestion schemas
# ---------------------------------------------------------------------------
@ -324,15 +481,6 @@ def _estimated_amount_per_use(category: ProductCategory) -> float | None:
return _ESTIMATED_AMOUNT_PER_USE.get(category)
def _net_weight_g(product: Product) -> float | None:
if product.full_weight_g is None or product.empty_weight_g is None:
return None
net = product.full_weight_g - product.empty_weight_g
if net <= 0:
return None
return net
def _price_per_use_pln(product: Product) -> float | None:
if product.price_amount is None or product.price_currency is None:
return None
@ -342,8 +490,6 @@ def _price_per_use_pln(product: Product) -> float | None:
return None
pack_amount = product.size_ml
if pack_amount is None or pack_amount <= 0:
pack_amount = _net_weight_g(product)
if pack_amount is None or pack_amount <= 0:
return None
@ -518,8 +664,10 @@ def create_product(data: ProductCreate, session: Session = Depends(get_session))
if payload.get("price_currency"):
payload["price_currency"] = str(payload["price_currency"]).upper()
product_id = uuid4()
product = Product(
id=uuid4(),
id=product_id,
short_id=str(product_id)[:8],
**payload,
)
session.add(product)
@ -599,8 +747,6 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine):
"price_amount": number,
"price_currency": string,
"size_ml": number,
"full_weight_g": number,
"empty_weight_g": number,
"pao_months": integer,
"inci": [string, ...],
"actives": [
@ -869,7 +1015,13 @@ def _ev(v: object) -> str:
return str(v)
def _build_shopping_context(session: Session, reference_date: date) -> str:
def _build_shopping_context(
session: Session,
reference_date: date,
*,
products: list[Product] | None = None,
last_used_on_by_product: dict[str, date] | None = None,
) -> str:
profile_ctx = build_user_profile_context(session, reference_date=reference_date)
snapshot = session.exec(
select(SkinConditionSnapshot).order_by(
@ -892,9 +1044,14 @@ def _build_shopping_context(session: Session, reference_date: date) -> str:
else:
skin_lines.append(" (brak danych)")
products = _get_shopping_products(session)
if products is None:
products = _get_shopping_products(session)
product_ids = [p.id for p in products]
last_used_on_by_product = last_used_on_by_product or build_last_used_on_by_product(
session,
product_ids=product_ids,
)
inventory_rows = (
session.exec(
select(ProductInventory).where(
@ -910,15 +1067,31 @@ def _build_shopping_context(session: Session, reference_date: date) -> str:
products_lines = ["POSIADANE PRODUKTY:"]
products_lines.append(
" Legenda: [✓] = produkt dostępny (w magazynie), [✗] = brak w magazynie"
" Legenda: [✓] = aktywny zapas istnieje, [✗] = brak aktywnego zapasu"
)
products_lines.append(
" Pola: stock_state, sealed_backup_count, lowest_remaining_level, days_since_last_used, replenishment_score, replenishment_priority_hint, repurchase_candidate"
)
for p in products:
active_inv = [i for i in inv_by_product.get(p.id, []) if i.finished_at is None]
has_stock = len(active_inv) > 0 # any unfinished inventory = in stock
stock = "" if has_stock else ""
inventory_summary = _summarize_inventory_state(inv_by_product.get(p.id, []))
stock = "" if inventory_summary["has_stock"] else ""
last_used_on = last_used_on_by_product.get(str(p.id))
days_since_last_used = _compute_days_since_last_used(
last_used_on, reference_date
)
replenishment = _compute_replenishment_score(
has_stock=bool(inventory_summary["has_stock"]),
sealed_backup_count=cast(int, inventory_summary["sealed_backup_count"]),
lowest_remaining_level=(
str(inventory_summary["lowest_opened_level"])
if inventory_summary["lowest_opened_level"] is not None
else None
),
days_since_last_used=days_since_last_used,
category=p.category,
)
actives = _extract_active_names(p)
actives_str = f", actives: {actives}" if actives else ""
ep = p.product_effect_profile
if isinstance(ep, dict):
@ -929,13 +1102,55 @@ def _build_shopping_context(session: Session, reference_date: date) -> str:
for k, v in ep.model_dump().items()
if v >= 3
}
effects_str = f", effects: {effects}" if effects else ""
targets = [_ev(t) for t in (p.targets or [])]
product_header = f" [{stock}] id={p.id} {p.name}"
if p.brand:
product_header += f" ({p.brand})"
products_lines.append(product_header)
products_lines.append(f" category={_ev(p.category)}")
products_lines.append(f" targets={targets}")
if actives:
products_lines.append(f" actives={actives}")
if effects:
products_lines.append(f" effects={effects}")
products_lines.append(f" stock_state={inventory_summary['stock_state']}")
products_lines.append(f" active_count={inventory_summary['active_count']}")
products_lines.append(f" opened_count={inventory_summary['opened_count']}")
products_lines.append(
f" [{stock}] id={p.id} {p.name} ({p.brand or ''}) - {_ev(p.category)}, "
f"targets: {targets}{actives_str}{effects_str}"
f" sealed_backup_count={inventory_summary['sealed_backup_count']}"
)
products_lines.append(
" lowest_remaining_level="
+ (
str(inventory_summary["lowest_opened_level"])
if inventory_summary["lowest_opened_level"] is not None
else "null"
)
)
products_lines.append(
" last_used_on=" + (last_used_on.isoformat() if last_used_on else "null")
)
products_lines.append(
" days_since_last_used="
+ (
str(days_since_last_used)
if days_since_last_used is not None
else "null"
)
)
products_lines.append(
f" replenishment_score={replenishment['replenishment_score']}"
)
products_lines.append(
" replenishment_priority_hint="
+ str(replenishment["replenishment_priority_hint"])
)
products_lines.append(
" repurchase_candidate="
+ ("true" if replenishment["repurchase_candidate"] else "false")
)
products_lines.append(
" reason_codes=" + str(replenishment["replenishment_reason_codes"])
)
return (
@ -973,32 +1188,46 @@ def _extract_requested_product_ids(
_SHOPPING_SYSTEM_PROMPT = """Jesteś asystentem zakupowym w dziedzinie pielęgnacji skóry.
Twoim zadaniem jest przeanalizować stan skóry użytkownika oraz produkty, które już posiada,
a następnie zasugerować TYPY produktów (bez marek), które mogłyby uzupełnić ich rutynę.
LEGENDA:
- [] = produkt dostępny w magazynie (nawet jeśli jest zapieczętowany)
- [] = produkt niedostępny (brak w magazynie, wszystkie opakowania zużyte)
Twoim zadaniem jest ocenić dwie rzeczy:
1. czy w rutynie użytkownika istnieją realne luki produktowe,
2. czy któryś z już posiadanych typów produktów warto odkupić teraz z powodu kończącego się zapasu.
ZASADY:
Masz działać konserwatywnie: nie sugeruj zakupu tylko dlatego, że coś mogłoby się przydać.
Sugestia ma pojawić się tylko wtedy, gdy istnieje wyraźny powód praktyczny.
WAŻNE POJĘCIA:
- `stock_state` opisuje stan zapasu: `out_of_stock`, `healthy`, `monitor`, `low`, `urgent`
- `sealed_backup_count` > 0 zwykle oznacza, że odkup nie jest pilny
- `lowest_remaining_level` dotyczy tylko otwartych opakowań i może mieć wartość: `high`, `medium`, `low`, `nearly_empty`
- `last_used_on` i `days_since_last_used` pomagają ocenić, czy produkt jest nadal faktycznie używany
- `replenishment_score`, `replenishment_priority_hint` i `repurchase_candidate` backendowym sygnałem pilności odkupu; traktuj je jako mocną wskazówkę
- `reason_codes` tłumaczą, dlaczego backend uznał odkup za pilny albo niepilny
JAK PODEJMOWAĆ DECYZJĘ:
0. Sugeruj tylko wtedy, gdy jest realna potrzeba - nie zwracaj stałej liczby produktów
1. Sugeruj TYLKO typy produktów, NIGDY konkretne marki (np. "Salicylic Acid 2% Masque", nie "La Roche-Posay")
2. Produkty oznaczone [] to te, których NIE MA w magazynie - możesz je zasugerować
3. Produkty oznaczone [] już dostępne - nie sugeruj ich ponownie
4. Bierz pod uwagę aktywne problemy skóry (acne, hyperpigmentacja, aging, etc.)
5. Sugeruj realistyczną częstotliwość użycia (dzienna, 2-3x tygodniowo, etc.)
6. Zachowaj kolejność warstw: cleanse toner serum moisturizer SPF
7. Jeśli użytkownik ma uszkodzoną barierę, unikaj silnych eksfoliantów i retinoidów
8. Zwracaj uwagę na ewentualne konflikty polecanych składników z tymi, które użytkownik już posiada (np. nie polecaj peptydów miedziowych jeśli użytkownik nadużywa kwasów)
9. Odpowiadaj w języku polskim
10. Używaj wyłącznie dozwolonych wartości enumów poniżej - nie twórz synonimów typu "night", "evening" ani "treatment"
11. Możesz zwrócić pustą listę suggestions, jeśli nie widzisz realnej potrzeby zakupowej
12. Każda sugestia ma mieć charakter decision-support: konkretnie wyjaśnij, dlaczego warto kupić teraz, jak wpisuje się w obecną rutynę i jakie ograniczenia
13. `short_reason` ma być krótkim, 1-zdaniowym skrótem decyzji zakupowej
14. `reason_to_buy_now` ma być konkretne i praktyczne, bez lania wody
15. `reason_not_needed_if_budget_tight` jest opcjonalne - uzupełniaj tylko wtedy, gdy zakup nie jest pilny lub istnieje rozsądny kompromis
16. `usage_cautions` ma być krótką listą praktycznych uwag; gdy brak istotnych zastrzeżeń zwróć pustą listę
17. `priority` ustawiaj jako: high = wyraźna luka lub pilna potrzeba, medium = sensowne uzupełnienie, low = opcjonalny upgrade
1. Najpierw oceń, czy użytkownik ma lukę w rutynie
2. Potem oceń, czy któryś istniejący typ produktu jest wart odkupu teraz
3. Nie rekomenduj ponownie produktu, który ma zdrowy zapas
4. Nie rekomenduj odkupu tylko dlatego, że produkt jest niski, jeśli nie był używany od dawna i nie wygląda na nadal potrzebny
5. Dla kategorii podstawowych (`cleanser`, `moisturizer`, `spf`) niski stan i świeże użycie mają większe znaczenie niż dla produktów okazjonalnych
6. Dla produktów okazjonalnych (`exfoliant`, `mask`, `spot_treatment`) świeżość użycia jest ważniejsza niż sam niski stan
7. Jeśli `sealed_backup_count` > 0, zwykle nie rekomenduj odkupu
8. Sugeruj TYLKO typy produktów, NIGDY konkretne marki (np. "Salicylic Acid 2% Masque", nie "La Roche-Posay")
9. Bierz pod uwagę aktywne problemy skóry (acne, hyperpigmentacja, aging, etc.)
10. Sugeruj realistyczną częstotliwość użycia (dzienna, 2-3x tygodniowo, etc.)
11. Zachowaj kolejność warstw: cleanse toner serum moisturizer SPF
12. Jeśli użytkownik ma uszkodzoną barierę, unikaj silnych eksfoliantów i retinoidów
13. Zwracaj uwagę na ewentualne konflikty polecanych składników z tymi, które użytkownik już posiada
14. Odpowiadaj w języku polskim
15. Używaj wyłącznie dozwolonych wartości enumów poniżej - nie twórz synonimów typu "night", "evening" ani "treatment"
16. Możesz zwrócić pustą listę suggestions, jeśli nie widzisz realnej potrzeby zakupowej
17. Każda sugestia ma mieć charakter decision-support: konkretnie wyjaśnij, dlaczego warto kupić teraz, jak wpisuje się w obecną rutynę i jakie ograniczenia
18. `short_reason` ma być krótkim, 1-zdaniowym skrótem decyzji zakupowej
19. `reason_to_buy_now` ma być konkretne i praktyczne, bez lania wody
20. `reason_not_needed_if_budget_tight` jest opcjonalne - uzupełniaj tylko wtedy, gdy zakup nie jest pilny lub istnieje rozsądny kompromis
21. `usage_cautions` ma być krótką listą praktycznych uwag; gdy brak istotnych zastrzeżeń zwróć pustą listę
22. `priority` ustawiaj jako: high = wyraźna luka lub pilna potrzeba, medium = sensowne uzupełnienie, low = opcjonalny upgrade
DOZWOLONE WARTOŚCI ENUMÓW:
- category: "cleanser" | "toner" | "essence" | "serum" | "moisturizer" | "spf" | "mask" | "exfoliant" | "hair_treatment" | "tool" | "spot_treatment" | "oil"
@ -1009,16 +1238,24 @@ Format odpowiedzi - zwróć wyłącznie JSON zgodny z podanym schematem."""
@router.post("/suggest", response_model=ShoppingSuggestionResponse)
def suggest_shopping(session: Session = Depends(get_session)):
context = _build_shopping_context(session, reference_date=date.today())
reference_date = date.today()
shopping_products = _get_shopping_products(session)
last_used_on_by_product = build_last_used_on_by_product(
session,
product_ids=[p.id for p in shopping_products],
)
context = _build_shopping_context(
session,
reference_date=reference_date,
products=shopping_products,
last_used_on_by_product=last_used_on_by_product,
)
prompt = (
f"Na podstawie poniższych danych przeanalizuj, jakie TYPY produktów "
f"mogłyby uzupełnić rutynę pielęgnacyjną użytkownika.\n\n"
"Przeanalizuj dane użytkownika i zaproponuj tylko te zakupy, które mają realny sens teraz.\n\n"
"Najpierw rozważ luki w rutynie, potem ewentualny odkup kończących się produktów.\n"
"Jeśli produkt już istnieje, ale ma niski zapas i jest nadal realnie używany, możesz zasugerować odkup tego typu produktu.\n"
"Jeśli produkt ma sealed backup albo nie był używany od dawna, zwykle nie sugeruj odkupu.\n\n"
f"{context}\n\n"
"NARZEDZIA:\n"
"- Masz dostep do funkcji: get_product_details.\n"

View file

@ -12,6 +12,7 @@ from .enums import (
PartOfDay,
PriceTier,
ProductCategory,
RemainingLevel,
ResultFlag,
RoutineRole,
SexAtBirth,
@ -58,6 +59,7 @@ __all__ = [
"OverallSkinState",
"PartOfDay",
"PriceTier",
"RemainingLevel",
"ProductCategory",
"ResultFlag",
"RoutineRole",

View file

@ -124,6 +124,13 @@ class PriceTier(str, Enum):
LUXURY = "luxury"
class RemainingLevel(str, Enum):
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
NEARLY_EMPTY = "nearly_empty"
class EvidenceLevel(str, Enum):
LOW = "low"
MIXED = "mixed"

View file

@ -14,6 +14,7 @@ from .enums import (
IngredientFunction,
PriceTier,
ProductCategory,
RemainingLevel,
SkinConcern,
SkinType,
StrengthLevel,
@ -97,8 +98,6 @@ class ProductBase(SQLModel):
price_amount: float | None = Field(default=None, gt=0)
price_currency: str | None = Field(default=None, min_length=3, max_length=3)
size_ml: float | None = Field(default=None, gt=0)
full_weight_g: float | None = Field(default=None, gt=0)
empty_weight_g: float | None = Field(default=None, gt=0)
pao_months: int | None = Field(default=None, ge=1, le=60)
inci: list[str] = Field(default_factory=list)
@ -129,7 +128,6 @@ class ProductBase(SQLModel):
needle_length_mm: float | None = Field(default=None, gt=0)
personal_tolerance_notes: str | None = None
personal_repurchase_intent: bool | None = None
# ---------------------------------------------------------------------------
@ -338,8 +336,6 @@ class Product(ProductBase, table=True):
ctx["needle_length_mm"] = self.needle_length_mm
if self.personal_tolerance_notes:
ctx["personal_tolerance_notes"] = self.personal_tolerance_notes
if self.personal_repurchase_intent is not None:
ctx["personal_repurchase_intent"] = self.personal_repurchase_intent
try:
opened_items = [
@ -365,8 +361,7 @@ class ProductInventory(SQLModel, table=True):
opened_at: date | None = Field(default=None)
finished_at: date | None = Field(default=None)
expiry_date: date | None = Field(default=None)
current_weight_g: float | None = Field(default=None, gt=0)
last_weighed_at: date | None = Field(default=None)
remaining_level: RemainingLevel | None = None
notes: str | None = None
created_at: datetime = Field(default_factory=utc_now, nullable=False)

View file

@ -24,12 +24,17 @@ def test_update_inventory_opened(client, created_product):
r2 = client.patch(
f"/inventory/{inv_id}",
json={"is_opened": True, "opened_at": "2026-01-15"},
json={
"is_opened": True,
"opened_at": "2026-01-15",
"remaining_level": "low",
},
)
assert r2.status_code == 200
data = r2.json()
assert data["is_opened"] is True
assert data["opened_at"] == "2026-01-15"
assert data["remaining_level"] == "low"
def test_update_inventory_not_found(client):

View file

@ -187,11 +187,15 @@ def test_list_inventory_product_not_found(client):
def test_create_inventory(client, created_product):
pid = created_product["id"]
r = client.post(f"/products/{pid}/inventory", json={"is_opened": False})
r = client.post(
f"/products/{pid}/inventory",
json={"is_opened": True, "remaining_level": "medium"},
)
assert r.status_code == 201
data = r.json()
assert data["product_id"] == pid
assert data["is_opened"] is False
assert data["is_opened"] is True
assert data["remaining_level"] == "medium"
def test_create_inventory_product_not_found(client):
@ -204,11 +208,16 @@ def test_parse_text_accepts_numeric_strength_levels(client, monkeypatch):
class _FakeResponse:
text = (
'{"name":"Test Serum","actives":[{"name":"Niacinamide","percent":10,'
'{"name":"Test Serum","category":"serum","recommended_time":"both",'
'"leave_on":true,"actives":[{"name":"Niacinamide","percent":10,'
'"functions":["niacinamide"],"strength_level":2,"irritation_potential":1}]}'
)
monkeypatch.setattr(products_api, "call_gemini", lambda **kwargs: _FakeResponse())
monkeypatch.setattr(
products_api,
"call_gemini",
lambda **kwargs: (_FakeResponse(), None),
)
r = client.post("/products/parse-text", json={"text": "dummy input"})
assert r.status_code == 200

View file

@ -8,6 +8,8 @@ from innercontext.api.products import (
ProductSuggestion,
ShoppingSuggestionResponse,
_build_shopping_context,
_compute_days_since_last_used,
_compute_replenishment_score,
_extract_requested_product_ids,
build_product_details_tool_handler,
)
@ -68,7 +70,12 @@ def test_build_shopping_context(session: Session):
session.commit()
# Add inventory
inv = ProductInventory(id=uuid.uuid4(), product_id=p.id, is_opened=True)
inv = ProductInventory(
id=uuid.uuid4(),
product_id=p.id,
is_opened=True,
remaining_level="medium",
)
session.add(inv)
session.commit()
@ -87,9 +94,89 @@ def test_build_shopping_context(session: Session):
assert "Soothing Serum" in ctx
assert f"id={p.id}" in ctx
assert "BrandX" in ctx
assert "targets: ['redness']" in ctx
assert "actives: ['Centella']" in ctx
assert "effects: {'soothing': 4}" in ctx
assert "targets=['redness']" in ctx
assert "actives=['Centella']" in ctx
assert "effects={'soothing': 4}" in ctx
assert "stock_state=monitor" in ctx
assert "opened_count=1" in ctx
assert "sealed_backup_count=0" in ctx
assert "lowest_remaining_level=medium" in ctx
assert "replenishment_score=30" in ctx
assert "replenishment_priority_hint=low" in ctx
assert "repurchase_candidate=true" in ctx
def test_build_shopping_context_flags_replenishment_signal(session: Session):
product = Product(
id=uuid.uuid4(),
short_id=str(uuid.uuid4())[:8],
name="Barrier Cleanser",
brand="BrandY",
category="cleanser",
recommended_time="both",
leave_on=False,
product_effect_profile={},
)
session.add(product)
session.commit()
session.add(
ProductInventory(
id=uuid.uuid4(),
product_id=product.id,
is_opened=True,
remaining_level="nearly_empty",
)
)
session.commit()
ctx = _build_shopping_context(session, reference_date=date.today())
assert "lowest_remaining_level=nearly_empty" in ctx
assert "stock_state=urgent" in ctx
assert "replenishment_priority_hint=high" in ctx
def test_compute_replenishment_score_prefers_recent_staples_without_backup():
result = _compute_replenishment_score(
has_stock=True,
sealed_backup_count=0,
lowest_remaining_level="low",
days_since_last_used=2,
category=ProductCategory.CLEANSER,
)
assert result["replenishment_score"] == 95
assert result["replenishment_priority_hint"] == "high"
assert result["repurchase_candidate"] is True
assert result["replenishment_reason_codes"] == [
"low_opened",
"recently_used",
"staple_category",
]
def test_compute_replenishment_score_downranks_sealed_backup_and_stale_usage():
result = _compute_replenishment_score(
has_stock=True,
sealed_backup_count=1,
lowest_remaining_level="nearly_empty",
days_since_last_used=70,
category=ProductCategory.EXFOLIANT,
)
assert result["replenishment_score"] == 0
assert result["replenishment_priority_hint"] == "none"
assert result["repurchase_candidate"] is False
assert result["replenishment_reason_codes"] == [
"has_sealed_backup",
"stale_usage",
"occasional_category",
]
def test_compute_days_since_last_used_returns_none_without_usage():
assert _compute_days_since_last_used(None, date(2026, 3, 9)) is None
assert _compute_days_since_last_used(date(2026, 3, 7), date(2026, 3, 9)) == 2
def test_suggest_shopping(client, session):

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