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 json
import logging import logging
from datetime import date from datetime import date
from typing import Any, Literal, Optional from typing import Any, Literal, Optional, cast
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@ -48,6 +48,7 @@ from innercontext.models.enums import (
AbsorptionSpeed, AbsorptionSpeed,
DayTime, DayTime,
PriceTier, PriceTier,
RemainingLevel,
SkinType, SkinType,
TextureType, TextureType,
) )
@ -128,8 +129,6 @@ class ProductUpdate(SQLModel):
price_amount: Optional[float] = None price_amount: Optional[float] = None
price_currency: Optional[str] = None price_currency: Optional[str] = None
size_ml: Optional[float] = None size_ml: Optional[float] = None
full_weight_g: Optional[float] = None
empty_weight_g: Optional[float] = None
pao_months: Optional[int] = None pao_months: Optional[int] = None
inci: Optional[list[str]] = None inci: Optional[list[str]] = None
@ -159,7 +158,6 @@ class ProductUpdate(SQLModel):
needle_length_mm: Optional[float] = None needle_length_mm: Optional[float] = None
personal_tolerance_notes: Optional[str] = None personal_tolerance_notes: Optional[str] = None
personal_repurchase_intent: Optional[bool] = None
class ProductParseRequest(SQLModel): class ProductParseRequest(SQLModel):
@ -181,8 +179,6 @@ class ProductParseResponse(SQLModel):
price_amount: Optional[float] = None price_amount: Optional[float] = None
price_currency: Optional[str] = None price_currency: Optional[str] = None
size_ml: Optional[float] = None size_ml: Optional[float] = None
full_weight_g: Optional[float] = None
empty_weight_g: Optional[float] = None
pao_months: Optional[int] = None pao_months: Optional[int] = None
inci: Optional[list[str]] = None inci: Optional[list[str]] = None
actives: Optional[list[ActiveIngredient]] = None actives: Optional[list[ActiveIngredient]] = None
@ -234,8 +230,7 @@ class InventoryCreate(SQLModel):
opened_at: Optional[date] = None opened_at: Optional[date] = None
finished_at: Optional[date] = None finished_at: Optional[date] = None
expiry_date: Optional[date] = None expiry_date: Optional[date] = None
current_weight_g: Optional[float] = None remaining_level: Optional[RemainingLevel] = None
last_weighed_at: Optional[date] = None
notes: Optional[str] = None notes: Optional[str] = None
@ -244,11 +239,173 @@ class InventoryUpdate(SQLModel):
opened_at: Optional[date] = None opened_at: Optional[date] = None
finished_at: Optional[date] = None finished_at: Optional[date] = None
expiry_date: Optional[date] = None expiry_date: Optional[date] = None
current_weight_g: Optional[float] = None remaining_level: Optional[RemainingLevel] = None
last_weighed_at: Optional[date] = None
notes: Optional[str] = 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 # Shopping suggestion schemas
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -324,15 +481,6 @@ def _estimated_amount_per_use(category: ProductCategory) -> float | None:
return _ESTIMATED_AMOUNT_PER_USE.get(category) 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: def _price_per_use_pln(product: Product) -> float | None:
if product.price_amount is None or product.price_currency is None: if product.price_amount is None or product.price_currency is None:
return None return None
@ -342,8 +490,6 @@ def _price_per_use_pln(product: Product) -> float | None:
return None return None
pack_amount = product.size_ml 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: if pack_amount is None or pack_amount <= 0:
return None return None
@ -518,8 +664,10 @@ def create_product(data: ProductCreate, session: Session = Depends(get_session))
if payload.get("price_currency"): if payload.get("price_currency"):
payload["price_currency"] = str(payload["price_currency"]).upper() payload["price_currency"] = str(payload["price_currency"]).upper()
product_id = uuid4()
product = Product( product = Product(
id=uuid4(), id=product_id,
short_id=str(product_id)[:8],
**payload, **payload,
) )
session.add(product) session.add(product)
@ -599,8 +747,6 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine):
"price_amount": number, "price_amount": number,
"price_currency": string, "price_currency": string,
"size_ml": number, "size_ml": number,
"full_weight_g": number,
"empty_weight_g": number,
"pao_months": integer, "pao_months": integer,
"inci": [string, ...], "inci": [string, ...],
"actives": [ "actives": [
@ -869,7 +1015,13 @@ def _ev(v: object) -> str:
return str(v) 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) profile_ctx = build_user_profile_context(session, reference_date=reference_date)
snapshot = session.exec( snapshot = session.exec(
select(SkinConditionSnapshot).order_by( select(SkinConditionSnapshot).order_by(
@ -892,9 +1044,14 @@ def _build_shopping_context(session: Session, reference_date: date) -> str:
else: else:
skin_lines.append(" (brak danych)") skin_lines.append(" (brak danych)")
if products is None:
products = _get_shopping_products(session) products = _get_shopping_products(session)
product_ids = [p.id for p in products] 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 = ( inventory_rows = (
session.exec( session.exec(
select(ProductInventory).where( select(ProductInventory).where(
@ -910,15 +1067,31 @@ def _build_shopping_context(session: Session, reference_date: date) -> str:
products_lines = ["POSIADANE PRODUKTY:"] products_lines = ["POSIADANE PRODUKTY:"]
products_lines.append( 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: for p in products:
active_inv = [i for i in inv_by_product.get(p.id, []) if i.finished_at is None] inventory_summary = _summarize_inventory_state(inv_by_product.get(p.id, []))
has_stock = len(active_inv) > 0 # any unfinished inventory = in stock stock = "" if inventory_summary["has_stock"] else ""
stock = "" if 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 = _extract_active_names(p)
actives_str = f", actives: {actives}" if actives else ""
ep = p.product_effect_profile ep = p.product_effect_profile
if isinstance(ep, dict): 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() for k, v in ep.model_dump().items()
if v >= 3 if v >= 3
} }
effects_str = f", effects: {effects}" if effects else ""
targets = [_ev(t) for t in (p.targets or [])] 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( products_lines.append(
f" [{stock}] id={p.id} {p.name} ({p.brand or ''}) - {_ev(p.category)}, " f" sealed_backup_count={inventory_summary['sealed_backup_count']}"
f"targets: {targets}{actives_str}{effects_str}" )
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 ( return (
@ -973,32 +1188,46 @@ def _extract_requested_product_ids(
_SHOPPING_SYSTEM_PROMPT = """Jesteś asystentem zakupowym w dziedzinie pielęgnacji skóry. _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: Twoim zadaniem jest ocenić dwie rzeczy:
- [] = produkt dostępny w magazynie (nawet jeśli jest zapieczętowany) 1. czy w rutynie użytkownika istnieją realne luki produktowe,
- [] = produkt niedostępny (brak w magazynie, wszystkie opakowania zużyte) 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 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") 1. Najpierw oceń, czy użytkownik ma lukę w rutynie
2. Produkty oznaczone [] to te, których NIE MA w magazynie - możesz je zasugerować 2. Potem oceń, czy któryś istniejący typ produktu jest wart odkupu teraz
3. Produkty oznaczone [] już dostępne - nie sugeruj ich ponownie 3. Nie rekomenduj ponownie produktu, który ma zdrowy zapas
4. Bierz pod uwagę aktywne problemy skóry (acne, hyperpigmentacja, aging, etc.) 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. Sugeruj realistyczną częstotliwość użycia (dzienna, 2-3x tygodniowo, etc.) 5. Dla kategorii podstawowych (`cleanser`, `moisturizer`, `spf`) niski stan i świeże użycie mają większe znaczenie niż dla produktów okazjonalnych
6. Zachowaj kolejność warstw: cleanse toner serum moisturizer SPF 6. Dla produktów okazjonalnych (`exfoliant`, `mask`, `spot_treatment`) świeżość użycia jest ważniejsza niż sam niski stan
7. Jeśli użytkownik ma uszkodzoną barierę, unikaj silnych eksfoliantów i retinoidów 7. Jeśli `sealed_backup_count` > 0, zwykle nie rekomenduj odkupu
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) 8. Sugeruj TYLKO typy produktów, NIGDY konkretne marki (np. "Salicylic Acid 2% Masque", nie "La Roche-Posay")
9. Odpowiadaj w języku polskim 9. Bierz pod uwagę aktywne problemy skóry (acne, hyperpigmentacja, aging, etc.)
10. Używaj wyłącznie dozwolonych wartości enumów poniżej - nie twórz synonimów typu "night", "evening" ani "treatment" 10. Sugeruj realistyczną częstotliwość użycia (dzienna, 2-3x tygodniowo, etc.)
11. Możesz zwrócić pustą listę suggestions, jeśli nie widzisz realnej potrzeby zakupowej 11. Zachowaj kolejność warstw: cleanse toner serum moisturizer SPF
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 12. Jeśli użytkownik ma uszkodzoną barierę, unikaj silnych eksfoliantów i retinoidów
13. `short_reason` ma być krótkim, 1-zdaniowym skrótem decyzji zakupowej 13. Zwracaj uwagę na ewentualne konflikty polecanych składników z tymi, które użytkownik już posiada
14. `reason_to_buy_now` ma być konkretne i praktyczne, bez lania wody 14. Odpowiadaj w języku polskim
15. `reason_not_needed_if_budget_tight` jest opcjonalne - uzupełniaj tylko wtedy, gdy zakup nie jest pilny lub istnieje rozsądny kompromis 15. Używaj wyłącznie dozwolonych wartości enumów poniżej - nie twórz synonimów typu "night", "evening" ani "treatment"
16. `usage_cautions` ma być krótką listą praktycznych uwag; gdy brak istotnych zastrzeżeń zwróć pustą listę 16. Możesz zwrócić pustą listę suggestions, jeśli nie widzisz realnej potrzeby zakupowej
17. `priority` ustawiaj jako: high = wyraźna luka lub pilna potrzeba, medium = sensowne uzupełnienie, low = opcjonalny upgrade 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: DOZWOLONE WARTOŚCI ENUMÓW:
- category: "cleanser" | "toner" | "essence" | "serum" | "moisturizer" | "spf" | "mask" | "exfoliant" | "hair_treatment" | "tool" | "spot_treatment" | "oil" - 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) @router.post("/suggest", response_model=ShoppingSuggestionResponse)
def suggest_shopping(session: Session = Depends(get_session)): 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) shopping_products = _get_shopping_products(session)
last_used_on_by_product = build_last_used_on_by_product( last_used_on_by_product = build_last_used_on_by_product(
session, session,
product_ids=[p.id for p in shopping_products], 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 = ( prompt = (
f"Na podstawie poniższych danych przeanalizuj, jakie TYPY produktów " "Przeanalizuj dane użytkownika i zaproponuj tylko te zakupy, które mają realny sens teraz.\n\n"
f"mogłyby uzupełnić rutynę pielęgnacyjną użytkownika.\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" f"{context}\n\n"
"NARZEDZIA:\n" "NARZEDZIA:\n"
"- Masz dostep do funkcji: get_product_details.\n" "- Masz dostep do funkcji: get_product_details.\n"

View file

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

View file

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

View file

@ -14,6 +14,7 @@ from .enums import (
IngredientFunction, IngredientFunction,
PriceTier, PriceTier,
ProductCategory, ProductCategory,
RemainingLevel,
SkinConcern, SkinConcern,
SkinType, SkinType,
StrengthLevel, StrengthLevel,
@ -97,8 +98,6 @@ class ProductBase(SQLModel):
price_amount: float | None = Field(default=None, gt=0) price_amount: float | None = Field(default=None, gt=0)
price_currency: str | None = Field(default=None, min_length=3, max_length=3) price_currency: str | None = Field(default=None, min_length=3, max_length=3)
size_ml: float | None = Field(default=None, gt=0) 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) pao_months: int | None = Field(default=None, ge=1, le=60)
inci: list[str] = Field(default_factory=list) inci: list[str] = Field(default_factory=list)
@ -129,7 +128,6 @@ class ProductBase(SQLModel):
needle_length_mm: float | None = Field(default=None, gt=0) needle_length_mm: float | None = Field(default=None, gt=0)
personal_tolerance_notes: str | None = None 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 ctx["needle_length_mm"] = self.needle_length_mm
if self.personal_tolerance_notes: if self.personal_tolerance_notes:
ctx["personal_tolerance_notes"] = 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: try:
opened_items = [ opened_items = [
@ -365,8 +361,7 @@ class ProductInventory(SQLModel, table=True):
opened_at: date | None = Field(default=None) opened_at: date | None = Field(default=None)
finished_at: date | None = Field(default=None) finished_at: date | None = Field(default=None)
expiry_date: date | None = Field(default=None) expiry_date: date | None = Field(default=None)
current_weight_g: float | None = Field(default=None, gt=0) remaining_level: RemainingLevel | None = None
last_weighed_at: date | None = Field(default=None)
notes: str | None = None notes: str | None = None
created_at: datetime = Field(default_factory=utc_now, nullable=False) 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( r2 = client.patch(
f"/inventory/{inv_id}", 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 assert r2.status_code == 200
data = r2.json() data = r2.json()
assert data["is_opened"] is True assert data["is_opened"] is True
assert data["opened_at"] == "2026-01-15" assert data["opened_at"] == "2026-01-15"
assert data["remaining_level"] == "low"
def test_update_inventory_not_found(client): 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): def test_create_inventory(client, created_product):
pid = created_product["id"] 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 assert r.status_code == 201
data = r.json() data = r.json()
assert data["product_id"] == pid 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): def test_create_inventory_product_not_found(client):
@ -204,11 +208,16 @@ def test_parse_text_accepts_numeric_strength_levels(client, monkeypatch):
class _FakeResponse: class _FakeResponse:
text = ( 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}]}' '"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"}) r = client.post("/products/parse-text", json={"text": "dummy input"})
assert r.status_code == 200 assert r.status_code == 200

View file

@ -8,6 +8,8 @@ from innercontext.api.products import (
ProductSuggestion, ProductSuggestion,
ShoppingSuggestionResponse, ShoppingSuggestionResponse,
_build_shopping_context, _build_shopping_context,
_compute_days_since_last_used,
_compute_replenishment_score,
_extract_requested_product_ids, _extract_requested_product_ids,
build_product_details_tool_handler, build_product_details_tool_handler,
) )
@ -68,7 +70,12 @@ def test_build_shopping_context(session: Session):
session.commit() session.commit()
# Add inventory # 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.add(inv)
session.commit() session.commit()
@ -87,9 +94,89 @@ def test_build_shopping_context(session: Session):
assert "Soothing Serum" in ctx assert "Soothing Serum" in ctx
assert f"id={p.id}" in ctx assert f"id={p.id}" in ctx
assert "BrandX" in ctx assert "BrandX" in ctx
assert "targets: ['redness']" in ctx assert "targets=['redness']" in ctx
assert "actives: ['Centella']" in ctx assert "actives=['Centella']" in ctx
assert "effects: {'soothing': 4}" 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): def test_suggest_shopping(client, session):

View file

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

View file

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

View file

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

View file

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

View file

@ -9,8 +9,6 @@
priceAmount = $bindable(''), priceAmount = $bindable(''),
priceCurrency = $bindable('PLN'), priceCurrency = $bindable('PLN'),
sizeMl = $bindable(''), sizeMl = $bindable(''),
fullWeightG = $bindable(''),
emptyWeightG = $bindable(''),
paoMonths = $bindable(''), paoMonths = $bindable(''),
phMin = $bindable(''), phMin = $bindable(''),
phMax = $bindable(''), phMax = $bindable(''),
@ -27,8 +25,6 @@
priceAmount?: string; priceAmount?: string;
priceCurrency?: string; priceCurrency?: string;
sizeMl?: string; sizeMl?: string;
fullWeightG?: string;
emptyWeightG?: string;
paoMonths?: string; paoMonths?: string;
phMin?: string; phMin?: string;
phMax?: 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} /> <Input id="size_ml" name="size_ml" type="number" min="0" step="0.1" placeholder={m["productForm_sizePlaceholder"]()} bind:value={sizeMl} />
</div> </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"> <div class="space-y-2">
<Label for="pao_months">{m["productForm_paoMonths"]()}</Label> <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} /> <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 { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
type TriOption = { value: string; label: string };
let { let {
visible = false, visible = false,
selectClass,
textareaClass, textareaClass,
tristate,
personalRepurchaseIntent = $bindable(''),
personalToleranceNotes = $bindable('') personalToleranceNotes = $bindable('')
}: { }: {
visible?: boolean; visible?: boolean;
selectClass: string;
textareaClass: string; textareaClass: string;
tristate: TriOption[];
personalRepurchaseIntent?: string;
personalToleranceNotes?: string; personalToleranceNotes?: string;
} = $props(); } = $props();
</script> </script>
@ -25,15 +17,6 @@
<Card class={visible ? '' : 'hidden'}> <Card class={visible ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_personalNotes"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["productForm_personalNotes"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4"> <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"> <div class="space-y-2">
<Label for="personal_tolerance_notes">{m["productForm_toleranceNotes"]()}</Label> <Label for="personal_tolerance_notes">{m["productForm_toleranceNotes"]()}</Label>
<textarea <textarea

View file

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

View file

@ -127,8 +127,6 @@ export const actions: Actions = {
// Optional numbers // Optional numbers
body.size_ml = parseOptionalFloat(form.get('size_ml') as string | null) ?? null; 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.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_min = parseOptionalFloat(form.get('ph_min') as string | null) ?? null;
body.ph_max = parseOptionalFloat(form.get('ph_max') 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'; body.is_tool = form.get('is_tool') === 'true';
// Nullable booleans // 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; body[field] = parseTristate(form.get(field) as string | null) ?? null;
} }
@ -192,10 +190,8 @@ export const actions: Actions = {
if (finishedAt) body.finished_at = finishedAt; if (finishedAt) body.finished_at = finishedAt;
const expiry = form.get('expiry_date'); const expiry = form.get('expiry_date');
if (expiry) body.expiry_date = expiry; if (expiry) body.expiry_date = expiry;
const weight = form.get('current_weight_g'); const remainingLevel = form.get('remaining_level');
if (weight) body.current_weight_g = Number(weight); if (remainingLevel) body.remaining_level = remainingLevel;
const lastWeighed = form.get('last_weighed_at');
if (lastWeighed) body.last_weighed_at = lastWeighed;
const notes = form.get('notes'); const notes = form.get('notes');
if (notes) body.notes = notes; if (notes) body.notes = notes;
try { try {
@ -219,10 +215,8 @@ export const actions: Actions = {
body.finished_at = finishedAt || null; body.finished_at = finishedAt || null;
const expiry = form.get('expiry_date'); const expiry = form.get('expiry_date');
body.expiry_date = expiry || null; body.expiry_date = expiry || null;
const weight = form.get('current_weight_g'); const remainingLevel = form.get('remaining_level');
body.current_weight_g = weight ? Number(weight) : null; body.remaining_level = remainingLevel || null;
const lastWeighed = form.get('last_weighed_at');
body.last_weighed_at = lastWeighed || null;
const notes = form.get('notes'); const notes = form.get('notes');
body.notes = notes || null; body.notes = notes || null;
try { try {

View file

@ -2,6 +2,7 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
@ -15,8 +16,16 @@
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
let { product } = $derived(data); 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 showInventoryForm = $state(false);
let addInventoryOpened = $state(false);
let editingInventoryId = $state<string | null>(null); let editingInventoryId = $state<string | null>(null);
let editingInventoryOpened = $state<Record<string, boolean>>({});
let activeTab = $state<'inventory' | 'edit'>('edit'); let activeTab = $state<'inventory' | 'edit'>('edit');
let isEditDirty = $state(false); let isEditDirty = $state(false);
let editSaveVersion = $state(0); let editSaveVersion = $state(0);
@ -60,6 +69,14 @@
if (value === 'luxury') return m['productForm_priceLuxury'](); if (value === 'luxury') return m['productForm_priceLuxury']();
return value; 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> </script>
<svelte:head><title>{product.name} — innercontext</title></svelte:head> <svelte:head><title>{product.name} — innercontext</title></svelte:head>
@ -150,9 +167,10 @@
<CardContent> <CardContent>
<form method="POST" action="?/addInventory" use:enhance class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <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"> <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" /> <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> <Label for="add_is_opened">{m["inventory_alreadyOpened"]()}</Label>
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<Label for="add_opened_at">{m["inventory_openedDate"]()}</Label> <Label for="add_opened_at">{m["inventory_openedDate"]()}</Label>
<Input id="add_opened_at" name="opened_at" type="date" /> <Input id="add_opened_at" name="opened_at" type="date" />
@ -165,14 +183,15 @@
<Label for="add_expiry_date">{m["inventory_expiryDate"]()}</Label> <Label for="add_expiry_date">{m["inventory_expiryDate"]()}</Label>
<Input id="add_expiry_date" name="expiry_date" type="date" /> <Input id="add_expiry_date" name="expiry_date" type="date" />
</div> </div>
<div class="space-y-1"> {#if addInventoryOpened}
<Label for="add_current_weight_g">{m["inventory_currentWeight"]()}</Label> <SimpleSelect
<Input id="add_current_weight_g" name="current_weight_g" type="number" min="0" /> id="add_remaining_level"
</div> name="remaining_level"
<div class="space-y-1"> label={m["inventory_remainingLevel"]()}
<Label for="add_last_weighed_at">{m["inventory_lastWeighed"]()}</Label> options={remainingLevelOptions}
<Input id="add_last_weighed_at" name="last_weighed_at" type="date" /> placeholder={m.common_unknown()}
</div> />
{/if}
<div class="space-y-1"> <div class="space-y-1">
<Label for="add_notes">{m.inventory_notes()}</Label> <Label for="add_notes">{m.inventory_notes()}</Label>
<Input id="add_notes" name="notes" /> <Input id="add_notes" name="notes" />
@ -206,11 +225,8 @@
{#if pkg.finished_at} {#if pkg.finished_at}
<span class="text-muted-foreground">{m.inventory_finished()} {pkg.finished_at.slice(0, 10)}</span> <span class="text-muted-foreground">{m.inventory_finished()} {pkg.finished_at.slice(0, 10)}</span>
{/if} {/if}
{#if pkg.current_weight_g} {#if pkg.is_opened && pkg.remaining_level}
<span class="text-muted-foreground">{pkg.current_weight_g}g {m.inventory_remaining()}</span> <span class="text-muted-foreground">{m["inventory_remainingLevel"]()} {remainingLevelLabel(pkg.remaining_level)}</span>
{/if}
{#if pkg.last_weighed_at}
<span class="text-muted-foreground">{m.inventory_weighed()} {pkg.last_weighed_at.slice(0, 10)}</span>
{/if} {/if}
{#if pkg.notes} {#if pkg.notes}
<span class="text-muted-foreground">{pkg.notes}</span> <span class="text-muted-foreground">{pkg.notes}</span>
@ -220,7 +236,14 @@
<Button <Button
variant="ghost" variant="ghost"
size="sm" 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()} {editingInventoryId === pkg.id ? m.common_cancel() : m.common_edit()}
</Button> </Button>
@ -256,7 +279,11 @@
id="edit_is_opened_{pkg.id}" id="edit_is_opened_{pkg.id}"
name="is_opened" name="is_opened"
value="true" 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" class="h-4 w-4"
/> />
<Label for="edit_is_opened_{pkg.id}">{m["inventory_alreadyOpened"]()}</Label> <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> <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) ?? ''} /> <Input id="edit_expiry_{pkg.id}" name="expiry_date" type="date" value={pkg.expiry_date?.slice(0, 10) ?? ''} />
</div> </div>
<div class="space-y-1"> {#if editingInventoryOpened[pkg.id] ?? pkg.is_opened}
<Label for="edit_weight_{pkg.id}">{m["inventory_currentWeight"]()}</Label> <SimpleSelect
<Input id="edit_weight_{pkg.id}" name="current_weight_g" type="number" min="0" value={pkg.current_weight_g ?? ''} /> id={`edit_remaining_level_${pkg.id}`}
</div> name="remaining_level"
<div class="space-y-1"> label={m["inventory_remainingLevel"]()}
<Label for="edit_last_weighed_{pkg.id}">{m["inventory_lastWeighed"]()}</Label> options={remainingLevelOptions}
<Input id="edit_last_weighed_{pkg.id}" name="last_weighed_at" type="date" value={pkg.last_weighed_at?.slice(0, 10) ?? ''} /> placeholder={m.common_unknown()}
</div> value={pkg.remaining_level ?? ''}
/>
{/if}
<div class="space-y-1"> <div class="space-y-1">
<Label for="edit_notes_{pkg.id}">{m.inventory_notes()}</Label> <Label for="edit_notes_{pkg.id}">{m.inventory_notes()}</Label>
<Input id="edit_notes_{pkg.id}" name="notes" value={pkg.notes ?? ''} /> <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); const size_ml = parseOptionalFloat(form.get('size_ml') as string | null);
if (size_ml !== undefined) payload.size_ml = size_ml; 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); const pao_months = parseOptionalInt(form.get('pao_months') as string | null);
if (pao_months !== undefined) payload.pao_months = pao_months; 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'; payload.is_tool = form.get('is_tool') === 'true';
// Nullable booleans // 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); const v = parseTristate(form.get(field) as string | null);
if (v !== undefined) payload[field] = v; if (v !== undefined) payload[field] = v;
} }