diff --git a/backend/alembic/versions/7c91e4b2af38_replace_price_tier_with_objective_price_fields.py b/backend/alembic/versions/7c91e4b2af38_replace_price_tier_with_objective_price_fields.py new file mode 100644 index 0000000..1030660 --- /dev/null +++ b/backend/alembic/versions/7c91e4b2af38_replace_price_tier_with_objective_price_fields.py @@ -0,0 +1,45 @@ +"""replace_price_tier_with_objective_price_fields + +Revision ID: 7c91e4b2af38 +Revises: e4f5a6b7c8d9 +Create Date: 2026-03-04 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +revision: str = "7c91e4b2af38" +down_revision: Union[str, None] = "e4f5a6b7c8d9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("products", sa.Column("price_amount", sa.Float(), nullable=True)) + op.add_column( + "products", sa.Column("price_currency", sa.String(length=3), nullable=True) + ) + op.drop_index(op.f("ix_products_price_tier"), table_name="products") + op.drop_column("products", "price_tier") + op.execute("DROP TYPE IF EXISTS pricetier") + + +def downgrade() -> None: + op.execute("CREATE TYPE pricetier AS ENUM ('BUDGET', 'MID', 'PREMIUM', 'LUXURY')") + op.add_column( + "products", + sa.Column( + "price_tier", + sa.Enum("BUDGET", "MID", "PREMIUM", "LUXURY", name="pricetier"), + nullable=True, + ), + ) + op.create_index( + op.f("ix_products_price_tier"), "products", ["price_tier"], unique=False + ) + op.drop_column("products", "price_currency") + op.drop_column("products", "price_amount") diff --git a/backend/alembic/versions/e4f5a6b7c8d9_drop_product_interaction_columns.py b/backend/alembic/versions/e4f5a6b7c8d9_drop_product_interaction_columns.py new file mode 100644 index 0000000..c09a6b2 --- /dev/null +++ b/backend/alembic/versions/e4f5a6b7c8d9_drop_product_interaction_columns.py @@ -0,0 +1,28 @@ +"""drop_product_interaction_columns + +Revision ID: e4f5a6b7c8d9 +Revises: d3e4f5a6b7c8 +Create Date: 2026-03-04 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +revision: str = "e4f5a6b7c8d9" +down_revision: Union[str, None] = "d3e4f5a6b7c8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_column("products", "incompatible_with") + op.drop_column("products", "synergizes_with") + + +def downgrade() -> None: + op.add_column("products", sa.Column("synergizes_with", sa.JSON(), nullable=True)) + op.add_column("products", sa.Column("incompatible_with", sa.JSON(), nullable=True)) diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index 743979b..36eef02 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -1,6 +1,6 @@ import json from datetime import date -from typing import Optional +from typing import Literal, Optional from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, HTTPException, Query @@ -17,6 +17,7 @@ from innercontext.llm import ( get_creative_config, get_extraction_config, ) +from innercontext.services.fx import convert_to_pln from innercontext.models import ( Product, ProductBase, @@ -38,7 +39,6 @@ from innercontext.models.product import ( ActiveIngredient, ProductContext, ProductEffectProfile, - ProductInteraction, ) router = APIRouter() @@ -68,8 +68,11 @@ class ProductUpdate(SQLModel): absorption_speed: Optional[AbsorptionSpeed] = None leave_on: Optional[bool] = None - price_tier: Optional[PriceTier] = None + 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 @@ -91,8 +94,6 @@ class ProductUpdate(SQLModel): ph_min: Optional[float] = None ph_max: Optional[float] = None - incompatible_with: Optional[list[ProductInteraction]] = None - synergizes_with: Optional[list[str]] = None context_rules: Optional[ProductContext] = None min_interval_hours: Optional[int] = None @@ -122,7 +123,8 @@ class ProductParseResponse(SQLModel): texture: Optional[TextureType] = None absorption_speed: Optional[AbsorptionSpeed] = None leave_on: Optional[bool] = None - price_tier: Optional[PriceTier] = None + 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 @@ -140,8 +142,6 @@ class ProductParseResponse(SQLModel): product_effect_profile: Optional[ProductEffectProfile] = None ph_min: Optional[float] = None ph_max: Optional[float] = None - incompatible_with: Optional[list[ProductInteraction]] = None - synergizes_with: Optional[list[str]] = None context_rules: Optional[ProductContext] = None min_interval_hours: Optional[int] = None max_frequency_per_week: Optional[int] = None @@ -218,6 +218,188 @@ class _ShoppingSuggestionsOut(PydanticBase): reasoning: str +# --------------------------------------------------------------------------- +# Pricing helpers +# --------------------------------------------------------------------------- + + +_MIN_PRODUCTS_FOR_PRICE_TIER = 8 +_MIN_CATEGORY_SIZE_FOR_FALLBACK = 4 +_ESTIMATED_AMOUNT_PER_USE: dict[ProductCategory, float] = { + ProductCategory.CLEANSER: 1.5, + ProductCategory.TONER: 1.5, + ProductCategory.ESSENCE: 1.0, + ProductCategory.SERUM: 0.35, + ProductCategory.MOISTURIZER: 0.8, + ProductCategory.SPF: 1.2, + ProductCategory.MASK: 2.5, + ProductCategory.EXFOLIANT: 0.7, + ProductCategory.HAIR_TREATMENT: 1.0, + ProductCategory.SPOT_TREATMENT: 0.1, + ProductCategory.OIL: 0.35, +} + + +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 + + amount_per_use = _estimated_amount_per_use(product.category) + if amount_per_use is None or amount_per_use <= 0: + 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 + + uses_per_pack = pack_amount / amount_per_use + if uses_per_pack <= 0: + return None + + price_pln = convert_to_pln(product.price_amount, product.price_currency.upper()) + if price_pln is None: + return None + return price_pln / uses_per_pack + + +def _percentile(sorted_values: list[float], fraction: float) -> float: + if not sorted_values: + raise ValueError("sorted_values cannot be empty") + if len(sorted_values) == 1: + return sorted_values[0] + + position = (len(sorted_values) - 1) * fraction + low = int(position) + high = min(low + 1, len(sorted_values) - 1) + weight = position - low + return sorted_values[low] * (1 - weight) + sorted_values[high] * weight + + +def _tier_from_thresholds( + value: float, + *, + p25: float, + p50: float, + p75: float, +) -> PriceTier: + if value <= p25: + return PriceTier.BUDGET + if value <= p50: + return PriceTier.MID + if value <= p75: + return PriceTier.PREMIUM + return PriceTier.LUXURY + + +def _thresholds(values: list[float]) -> tuple[float, float, float]: + sorted_vals = sorted(values) + return ( + _percentile(sorted_vals, 0.25), + _percentile(sorted_vals, 0.50), + _percentile(sorted_vals, 0.75), + ) + + +def _compute_pricing_outputs( + products: list[Product], +) -> dict[ + UUID, + tuple[ + PriceTier | None, + float | None, + Literal["category", "fallback", "insufficient_data"] | None, + ], +]: + price_per_use_by_id: dict[UUID, float] = {} + grouped: dict[ProductCategory, list[tuple[UUID, float]]] = {} + + for product in products: + ppu = _price_per_use_pln(product) + if ppu is None: + continue + price_per_use_by_id[product.id] = ppu + grouped.setdefault(product.category, []).append((product.id, ppu)) + + outputs: dict[ + UUID, + tuple[ + PriceTier | None, + float | None, + Literal["category", "fallback", "insufficient_data"] | None, + ], + ] = { + p.id: ( + None, + price_per_use_by_id.get(p.id), + "insufficient_data" if p.id in price_per_use_by_id else None, + ) + for p in products + } + + fallback_rows: list[tuple[UUID, float]] = [] + for product in products: + ppu = price_per_use_by_id.get(product.id) + if ppu is None: + continue + if product.is_tool or product.is_medication or not product.leave_on: + continue + fallback_rows.append((product.id, ppu)) + + fallback_thresholds: tuple[float, float, float] | None = None + if len(fallback_rows) >= _MIN_PRODUCTS_FOR_PRICE_TIER: + fallback_thresholds = _thresholds([ppu for _, ppu in fallback_rows]) + + for category_rows in grouped.values(): + if len(category_rows) < _MIN_PRODUCTS_FOR_PRICE_TIER: + if ( + len(category_rows) >= _MIN_CATEGORY_SIZE_FOR_FALLBACK + and fallback_thresholds is not None + ): + p25, p50, p75 = fallback_thresholds + for product_id, ppu in category_rows: + tier = _tier_from_thresholds(ppu, p25=p25, p50=p50, p75=p75) + outputs[product_id] = (tier, ppu, "fallback") + continue + + p25, p50, p75 = _thresholds([ppu for _, ppu in category_rows]) + + for product_id, ppu in category_rows: + tier = _tier_from_thresholds(ppu, p25=p25, p50=p50, p75=p75) + outputs[product_id] = (tier, ppu, "category") + + return outputs + + +def _with_pricing( + view: ProductPublic, + pricing: tuple[ + PriceTier | None, + float | None, + Literal["category", "fallback", "insufficient_data"] | None, + ], +) -> ProductPublic: + price_tier, price_per_use_pln, price_tier_source = pricing + view.price_tier = price_tier + view.price_per_use_pln = price_per_use_pln + view.price_tier_source = price_tier_source + return view + + # --------------------------------------------------------------------------- # Product routes # --------------------------------------------------------------------------- @@ -272,8 +454,12 @@ def list_products( inv_by_product.setdefault(inv.product_id, []).append(inv) results = [] + pricing_pool = list(session.exec(select(Product)).all()) if products else [] + pricing_outputs = _compute_pricing_outputs(pricing_pool) + for p in products: r = ProductWithInventory.model_validate(p, from_attributes=True) + _with_pricing(r, pricing_outputs.get(p.id, (None, None, None))) r.inventory = inv_by_product.get(p.id, []) results.append(r) return results @@ -281,9 +467,13 @@ def list_products( @router.post("", response_model=ProductPublic, status_code=201) def create_product(data: ProductCreate, session: Session = Depends(get_session)): + payload = data.model_dump() + if payload.get("price_currency"): + payload["price_currency"] = str(payload["price_currency"]).upper() + product = Product( id=uuid4(), - **data.model_dump(), + **payload, ) session.add(product) session.commit() @@ -329,8 +519,6 @@ texture: "watery" | "gel" | "emulsion" | "cream" | "oil" | "balm" | "foam" | "fl absorption_speed: "very_fast" | "fast" | "moderate" | "slow" | "very_slow" -price_tier: "budget" | "mid" | "premium" | "luxury" - recommended_for (array, pick applicable): "dry" | "oily" | "combination" | "sensitive" | "normal" | "acne_prone" @@ -347,8 +535,6 @@ actives[].functions (array, pick applicable): actives[].strength_level: 1 (low) | 2 (medium) | 3 (high) actives[].irritation_potential: 1 (low) | 2 (medium) | 3 (high) -incompatible_with[].scope: "same_step" | "same_day" | "same_period" - OUTPUT SCHEMA (all fields optional — omit what you cannot determine): { "name": string, @@ -362,7 +548,8 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine): "texture": string, "absorption_speed": string, "leave_on": boolean, - "price_tier": string, + "price_amount": number, + "price_currency": string, "size_ml": number, "full_weight_g": number, "empty_weight_g": number, @@ -402,10 +589,6 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine): }, "ph_min": number, "ph_max": number, - "incompatible_with": [ - {"target": string, "scope": string, "reason": string} - ], - "synergizes_with": [string, ...], "context_rules": { "safe_after_shaving": boolean, "safe_after_acids": boolean, @@ -451,10 +634,14 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse: @router.get("/{product_id}", response_model=ProductWithInventory) def get_product(product_id: UUID, session: Session = Depends(get_session)): product = get_or_404(session, Product, product_id) + pricing_pool = list(session.exec(select(Product)).all()) + pricing_outputs = _compute_pricing_outputs(pricing_pool) + inventory = session.exec( select(ProductInventory).where(ProductInventory.product_id == product_id) ).all() result = ProductWithInventory.model_validate(product, from_attributes=True) + _with_pricing(result, pricing_outputs.get(product.id, (None, None, None))) result.inventory = list(inventory) return result @@ -464,12 +651,19 @@ def update_product( product_id: UUID, data: ProductUpdate, session: Session = Depends(get_session) ): product = get_or_404(session, Product, product_id) - for key, value in data.model_dump(exclude_unset=True).items(): + patch_data = data.model_dump(exclude_unset=True) + if patch_data.get("price_currency"): + patch_data["price_currency"] = str(patch_data["price_currency"]).upper() + + for key, value in patch_data.items(): setattr(product, key, value) session.add(product) session.commit() session.refresh(product) - return product + pricing_pool = list(session.exec(select(Product)).all()) + pricing_outputs = _compute_pricing_outputs(pricing_pool) + result = ProductPublic.model_validate(product, from_attributes=True) + return _with_pricing(result, pricing_outputs.get(product.id, (None, None, None))) @router.delete("/{product_id}", status_code=204) @@ -721,7 +915,6 @@ def _build_safety_rules_tool_handler(products: list[Product]): return { "id": pid, "name": product.name, - "incompatible_with": (ctx.get("incompatible_with") or [])[:24], "contraindications": (ctx.get("contraindications") or [])[:24], "context_rules": ctx.get("context_rules") or {}, "safety": ctx.get("safety") or {}, @@ -755,7 +948,7 @@ _SAFETY_RULES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration( name="get_product_safety_rules", description=( "Return safety and compatibility rules for selected product UUIDs, " - "including incompatible_with, contraindications, context_rules and safety flags." + "including contraindications, context_rules and safety flags." ), parameters=genai_types.Schema( type=genai_types.Type.OBJECT, diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index 503e0c9..0cd860e 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -378,8 +378,6 @@ def _build_products_context( notable = {k: v for k, v in profile.items() if v and v > 0} if notable: entry += f" effects={notable}" - if ctx.get("incompatible_with"): - entry += f" incompatible_with={ctx['incompatible_with']}" if ctx.get("contraindications"): entry += f" contraindications={ctx['contraindications']}" if ctx.get("context_rules"): @@ -501,7 +499,6 @@ def _build_safety_rules_tool_handler( return { "id": pid, "name": product.name, - "incompatible_with": (ctx.get("incompatible_with") or [])[:24], "contraindications": (ctx.get("contraindications") or [])[:24], "context_rules": ctx.get("context_rules") or {}, "safety": ctx.get("safety") or {}, @@ -630,7 +627,7 @@ _SAFETY_RULES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration( name="get_product_safety_rules", description=( "Return safety and compatibility rules for selected product UUIDs: " - "incompatible_with, contraindications, context_rules and safety flags." + "contraindications, context_rules and safety flags." ), parameters=genai_types.Schema( type=genai_types.Type.OBJECT, @@ -688,8 +685,7 @@ WYMAGANIA ODPOWIEDZI: ZASADY PLANOWANIA: - Kolejność warstw: cleanser -> toner -> essence -> serum -> moisturizer -> [SPF dla AM]. -- Respektuj: incompatible_with (same_step / same_day / same_period), context_rules, - min_interval_hours, max_frequency_per_week, usage_notes. +- Respektuj: context_rules, min_interval_hours, max_frequency_per_week, usage_notes. - Zarządzanie inwentarzem: - Preferuj produkty już otwarte (miękka preferencja). - Unikaj funkcjonalnej redundancji (np. wielokrotne źródła panthenolu, ceramidów lub niacynamidu w tej samej rutynie), diff --git a/backend/innercontext/models/__init__.py b/backend/innercontext/models/__init__.py index 5fa50b0..73939cc 100644 --- a/backend/innercontext/models/__init__.py +++ b/backend/innercontext/models/__init__.py @@ -7,7 +7,6 @@ from .enums import ( EvidenceLevel, GroomingAction, IngredientFunction, - InteractionScope, MedicationKind, OverallSkinState, PartOfDay, @@ -29,7 +28,6 @@ from .product import ( ProductBase, ProductContext, ProductEffectProfile, - ProductInteraction, ProductInventory, ProductPublic, ProductWithInventory, @@ -53,7 +51,6 @@ __all__ = [ "EvidenceLevel", "GroomingAction", "IngredientFunction", - "InteractionScope", "MedicationKind", "OverallSkinState", "PartOfDay", @@ -77,7 +74,6 @@ __all__ = [ "ProductBase", "ProductContext", "ProductEffectProfile", - "ProductInteraction", "ProductInventory", "ProductPublic", "ProductWithInventory", diff --git a/backend/innercontext/models/enums.py b/backend/innercontext/models/enums.py index 293e280..a18e85c 100644 --- a/backend/innercontext/models/enums.py +++ b/backend/innercontext/models/enums.py @@ -131,12 +131,6 @@ class EvidenceLevel(str, Enum): HIGH = "high" -class InteractionScope(str, Enum): - SAME_STEP = "same_step" - SAME_DAY = "same_day" - SAME_PERIOD = "same_period" - - # --------------------------------------------------------------------------- # Health # --------------------------------------------------------------------------- diff --git a/backend/innercontext/models/product.py b/backend/innercontext/models/product.py index bb5dc0b..afd74c7 100644 --- a/backend/innercontext/models/product.py +++ b/backend/innercontext/models/product.py @@ -12,7 +12,6 @@ from .enums import ( AbsorptionSpeed, DayTime, IngredientFunction, - InteractionScope, PriceTier, ProductCategory, SkinConcern, @@ -57,12 +56,6 @@ class ActiveIngredient(SQLModel): irritation_potential: StrengthLevel | None = None -class ProductInteraction(SQLModel): - target: str - scope: InteractionScope - reason: str | None = None - - class ProductContext(SQLModel): safe_after_shaving: bool | None = None safe_after_acids: bool | None = None @@ -101,7 +94,8 @@ class ProductBase(SQLModel): absorption_speed: AbsorptionSpeed | None = None leave_on: bool - price_tier: PriceTier | None = None + 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) @@ -128,8 +122,6 @@ class ProductBase(SQLModel): ph_min: float | None = Field(default=None, ge=0, le=14) ph_max: float | None = Field(default=None, ge=0, le=14) - incompatible_with: list[ProductInteraction] | None = None - synergizes_with: list[str] | None = None context_rules: ProductContext | None = None min_interval_hours: int | None = Field(default=None, ge=0) @@ -154,9 +146,6 @@ class Product(ProductBase, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) - # Override: add index for table context - price_tier: PriceTier | None = Field(default=None, index=True) - # Override 9 JSON fields with sa_column (only in table model) inci: list[str] = Field( default_factory=list, sa_column=Column(JSON, nullable=False) @@ -181,12 +170,6 @@ class Product(ProductBase, table=True): sa_column=Column(JSON, nullable=False), ) - incompatible_with: list[ProductInteraction] | None = Field( - default=None, sa_column=Column(JSON, nullable=True) - ) - synergizes_with: list[str] | None = Field( - default=None, sa_column=Column(JSON, nullable=True) - ) context_rules: ProductContext | None = Field( default=None, sa_column=Column(JSON, nullable=True) ) @@ -232,9 +215,17 @@ class Product(ProductBase, table=True): if self.is_medication and not self.usage_notes: raise ValueError("Medication products must have usage_notes") + if self.price_currency is not None: + self.price_currency = self.price_currency.upper() + return self - def to_llm_context(self) -> dict: + def to_llm_context( + self, + *, + computed_price_tier: PriceTier | None = None, + price_per_use_pln: float | None = None, + ) -> dict: ctx: dict = { "id": str(self.id), "name": self.name, @@ -253,8 +244,14 @@ class Product(ProductBase, table=True): ctx["texture"] = _ev(self.texture) if self.absorption_speed is not None: ctx["absorption_speed"] = _ev(self.absorption_speed) - if self.price_tier is not None: - ctx["price_tier"] = _ev(self.price_tier) + if self.price_amount is not None: + ctx["price_amount"] = self.price_amount + if self.price_currency is not None: + ctx["price_currency"] = self.price_currency + if computed_price_tier is not None: + ctx["price_tier"] = _ev(computed_price_tier) + if price_per_use_pln is not None: + ctx["price_per_use_pln"] = round(price_per_use_pln, 4) if self.size_ml is not None: ctx["size_ml"] = self.size_ml if self.pao_months is not None: @@ -302,26 +299,6 @@ class Product(ProductBase, table=True): if nonzero: ctx["effect_profile"] = nonzero - if self.incompatible_with: - parts = [] - for inc in self.incompatible_with: - if isinstance(inc, dict): - scope = inc.get("scope", "") - target = inc.get("target", "") - reason = inc.get("reason") - else: - scope = _ev(inc.scope) - target = inc.target - reason = inc.reason - msg = f"avoid {target} ({scope})" - if reason: - msg += f": {reason}" - parts.append(msg) - ctx["incompatible_with"] = parts - - if self.synergizes_with: - ctx["synergizes_with"] = self.synergizes_with - if self.context_rules is not None: cr = self.context_rules if isinstance(cr, dict): @@ -405,6 +382,9 @@ class ProductPublic(ProductBase): id: UUID created_at: datetime updated_at: datetime + price_tier: PriceTier | None = None + price_per_use_pln: float | None = None + price_tier_source: str | None = None class ProductWithInventory(ProductPublic): diff --git a/backend/innercontext/services/__init__.py b/backend/innercontext/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/innercontext/services/fx.py b/backend/innercontext/services/fx.py new file mode 100644 index 0000000..f9f13c0 --- /dev/null +++ b/backend/innercontext/services/fx.py @@ -0,0 +1,77 @@ +import json +from datetime import datetime, timedelta, timezone +from threading import Lock +from urllib.error import URLError +from urllib.request import Request, urlopen + +NBP_TABLE_A_URL = "https://api.nbp.pl/api/exchangerates/tables/A" +_CACHE_TTL = timedelta(hours=24) + +_cache_lock = Lock() +_cached_rates: dict[str, float] | None = None +_cached_at: datetime | None = None + + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def _cache_is_fresh() -> bool: + if _cached_rates is None or _cached_at is None: + return False + return _now_utc() - _cached_at < _CACHE_TTL + + +def _fetch_rates_from_nbp() -> dict[str, float]: + req = Request(NBP_TABLE_A_URL, headers={"Accept": "application/json"}) + with urlopen(req, timeout=10) as response: + payload = json.loads(response.read().decode("utf-8")) + + if not isinstance(payload, list) or not payload: + raise ValueError("Unexpected NBP payload") + + table = payload[0] + rates = table.get("rates") if isinstance(table, dict) else None + if not isinstance(rates, list): + raise ValueError("NBP payload does not include rates") + + parsed: dict[str, float] = {"PLN": 1.0} + for row in rates: + if not isinstance(row, dict): + continue + code = row.get("code") + mid = row.get("mid") + if not isinstance(code, str) or not isinstance(mid, (int, float)): + continue + parsed[code.upper()] = float(mid) + + return parsed + + +def get_pln_rates() -> dict[str, float]: + global _cached_rates, _cached_at + + with _cache_lock: + if _cache_is_fresh() and _cached_rates is not None: + return dict(_cached_rates) + + try: + fresh = _fetch_rates_from_nbp() + except (URLError, TimeoutError, ValueError): + if _cached_rates is not None: + return dict(_cached_rates) + return {"PLN": 1.0} + + _cached_rates = fresh + _cached_at = _now_utc() + return dict(fresh) + + +def convert_to_pln(amount: float, currency: str) -> float | None: + if amount <= 0: + return None + rates = get_pln_rates() + rate = rates.get(currency.upper()) + if rate is None: + return None + return amount * rate diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 49f1708..eeddb55 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "alembic>=1.14", "fastapi>=0.132.0", "google-genai>=1.65.0", - "psycopg>=3.3.3", + "psycopg[binary]>=3.3.3", "python-dotenv>=1.2.1", "python-multipart>=0.0.22", "sqlmodel>=0.0.37", diff --git a/backend/tests/test_product_model.py b/backend/tests/test_product_model.py index f2076b3..77290ac 100644 --- a/backend/tests/test_product_model.py +++ b/backend/tests/test_product_model.py @@ -7,14 +7,12 @@ from innercontext.models import Product from innercontext.models.enums import ( DayTime, IngredientFunction, - InteractionScope, ProductCategory, ) from innercontext.models.product import ( ActiveIngredient, ProductContext, ProductEffectProfile, - ProductInteraction, ) @@ -156,29 +154,6 @@ def test_effect_profile_nonzero_included(): assert "retinoid_strength" not in ctx["effect_profile"] -# --------------------------------------------------------------------------- -# Incompatible_with -# --------------------------------------------------------------------------- - - -def test_incompatible_with_pydantic_objects(): - inc = ProductInteraction( - target="AHA", scope=InteractionScope.SAME_DAY, reason="increases irritation" - ) - p = _make(incompatible_with=[inc]) - ctx = p.to_llm_context() - assert "incompatible_with" in ctx - assert ctx["incompatible_with"][0] == "avoid AHA (same_day): increases irritation" - - -def test_incompatible_with_raw_dicts(): - raw = {"target": "Vitamin C", "scope": "same_step", "reason": None} - p = _make(incompatible_with=[raw]) - ctx = p.to_llm_context() - assert "incompatible_with" in ctx - assert ctx["incompatible_with"][0] == "avoid Vitamin C (same_step)" - - # --------------------------------------------------------------------------- # Context rules # --------------------------------------------------------------------------- diff --git a/backend/tests/test_products_helpers.py b/backend/tests/test_products_helpers.py index 805f35f..c4046f9 100644 --- a/backend/tests/test_products_helpers.py +++ b/backend/tests/test_products_helpers.py @@ -136,7 +136,6 @@ def test_shopping_tool_handlers_return_payloads(session: Session): usage_notes="Use AM and PM on clean skin.", inci=["Water", "Niacinamide"], actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}], - incompatible_with=[{"target": "Vitamin C", "scope": "same_step"}], context_rules={"safe_after_shaving": True}, product_effect_profile={}, ) @@ -153,5 +152,4 @@ def test_shopping_tool_handlers_return_payloads(session: Session): assert notes_data["products"][0]["usage_notes"] == "Use AM and PM on clean skin." safety_data = _build_safety_rules_tool_handler([product])(payload) - assert "incompatible_with" in safety_data["products"][0] assert "context_rules" in safety_data["products"][0] diff --git a/backend/tests/test_products_pricing.py b/backend/tests/test_products_pricing.py new file mode 100644 index 0000000..1abdeca --- /dev/null +++ b/backend/tests/test_products_pricing.py @@ -0,0 +1,177 @@ +import uuid + +from innercontext.api import products as products_api +from innercontext.models import Product +from innercontext.models.enums import DayTime, ProductCategory + + +def _product( + *, category: ProductCategory, price_amount: float, size_ml: float +) -> Product: + return Product( + id=uuid.uuid4(), + name=f"{category}-{price_amount}", + brand="Brand", + category=category, + recommended_time=DayTime.BOTH, + leave_on=True, + price_amount=price_amount, + price_currency="PLN", + size_ml=size_ml, + ) + + +def test_compute_pricing_outputs_groups_by_category(monkeypatch): + monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount) + + serums = [ + _product(category=ProductCategory.SERUM, price_amount=float(i), size_ml=35.0) + for i in range(10, 90, 10) + ] + cleansers = [ + _product( + category=ProductCategory.CLEANSER, price_amount=float(i), size_ml=200.0 + ) + for i in range(40, 120, 10) + ] + outputs = products_api._compute_pricing_outputs(serums + cleansers) + + serum_tiers = [outputs[p.id][0] for p in serums] + cleanser_tiers = [outputs[p.id][0] for p in cleansers] + + assert serum_tiers[0] == "budget" + assert serum_tiers[-1] == "luxury" + assert cleanser_tiers[0] == "budget" + assert cleanser_tiers[-1] == "luxury" + + +def test_price_tier_is_null_when_not_enough_products(client, monkeypatch): + monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount) + + base = { + "brand": "B", + "recommended_time": "both", + "leave_on": True, + "size_ml": 35.0, + "price_currency": "PLN", + } + for i in range(7): + response = client.post( + "/products", + json={ + **base, + "name": f"Serum {i}", + "category": "serum", + "price_amount": 30 + i, + }, + ) + assert response.status_code == 201 + + products = client.get("/products").json() + assert len(products) == 7 + assert all(p["price_tier"] is None for p in products) + assert all(p["price_per_use_pln"] is not None for p in products) + + +def test_price_tier_is_computed_on_list(client, monkeypatch): + monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount) + + base = { + "brand": "B", + "recommended_time": "both", + "leave_on": True, + "size_ml": 35.0, + "price_currency": "PLN", + "category": "serum", + } + for i in range(8): + response = client.post( + "/products", + json={**base, "name": f"Serum {i}", "price_amount": 20 + i * 10}, + ) + assert response.status_code == 201 + + products = client.get("/products").json() + assert len(products) == 8 + assert any(p["price_tier"] == "budget" for p in products) + assert any(p["price_tier"] == "luxury" for p in products) + + +def test_price_tier_uses_fallback_for_medium_categories(client, monkeypatch): + monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount) + + serum_base = { + "brand": "B", + "recommended_time": "both", + "leave_on": True, + "size_ml": 35.0, + "price_currency": "PLN", + "category": "serum", + } + for i in range(8): + response = client.post( + "/products", + json={**serum_base, "name": f"Serum {i}", "price_amount": 20 + i * 5}, + ) + assert response.status_code == 201 + + toner_base = { + "brand": "B", + "recommended_time": "both", + "leave_on": True, + "size_ml": 100.0, + "price_currency": "PLN", + "category": "toner", + } + for i in range(5): + response = client.post( + "/products", + json={**toner_base, "name": f"Toner {i}", "price_amount": 10 + i * 5}, + ) + assert response.status_code == 201 + + products = client.get("/products?category=toner").json() + assert len(products) == 5 + assert all(p["price_tier"] is not None for p in products) + assert all(p["price_tier_source"] == "fallback" for p in products) + + +def test_price_tier_stays_null_for_tiny_categories_even_with_fallback_pool( + client, monkeypatch +): + monkeypatch.setattr(products_api, "convert_to_pln", lambda amount, currency: amount) + + serum_base = { + "brand": "B", + "recommended_time": "both", + "leave_on": True, + "size_ml": 35.0, + "price_currency": "PLN", + "category": "serum", + } + for i in range(8): + response = client.post( + "/products", + json={**serum_base, "name": f"Serum {i}", "price_amount": 20 + i * 5}, + ) + assert response.status_code == 201 + + oil_base = { + "brand": "B", + "recommended_time": "both", + "leave_on": True, + "size_ml": 30.0, + "price_currency": "PLN", + "category": "oil", + } + for i in range(3): + response = client.post( + "/products", + json={**oil_base, "name": f"Oil {i}", "price_amount": 30 + i * 10}, + ) + assert response.status_code == 201 + + oils = client.get("/products?category=oil").json() + assert len(oils) == 3 + assert all(p["price_tier"] is None for p in oils) + assert all(p["price_tier_source"] == "insufficient_data" for p in oils) diff --git a/backend/tests/test_routines_helpers.py b/backend/tests/test_routines_helpers.py index 5d78872..9e0ec09 100644 --- a/backend/tests/test_routines_helpers.py +++ b/backend/tests/test_routines_helpers.py @@ -184,7 +184,6 @@ def test_build_products_context(session: Session): recommended_time="am", pao_months=6, product_effect_profile={"hydration_immediate": 2, "exfoliation_strength": 0}, - incompatible_with=[{"target": "retinol", "scope": "same_routine"}], context_rules={"safe_after_shaving": False}, min_interval_hours=12, max_frequency_per_week=7, @@ -233,7 +232,6 @@ def test_build_products_context(session: Session): assert "nearest_open_pao_deadline=" in ctx assert "pao_months=6" in ctx assert "effects={'hydration_immediate': 2}" in ctx - assert "incompatible_with=['avoid retinol (same_routine)']" in ctx assert "context_rules={'safe_after_shaving': False}" in ctx assert "min_interval_hours=12" in ctx assert "max_frequency_per_week=7" in ctx @@ -390,7 +388,6 @@ def test_additional_tool_handlers_return_product_payloads(session: Session): leave_on=True, usage_notes="Apply morning and evening.", actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}], - incompatible_with=[{"target": "Retinol", "scope": "same_step"}], context_rules={"safe_after_shaving": True}, product_effect_profile={}, ) @@ -404,5 +401,4 @@ def test_additional_tool_handlers_return_product_payloads(session: Session): assert notes_out["products"][0]["usage_notes"] == "Apply morning and evening." safety_out = _build_safety_rules_tool_handler([p])(ids_payload) - assert "incompatible_with" in safety_out["products"][0] assert "context_rules" in safety_out["products"][0] diff --git a/backend/uv.lock b/backend/uv.lock index 52286f9..a911208 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -556,7 +556,7 @@ dependencies = [ { name = "alembic" }, { name = "fastapi" }, { name = "google-genai" }, - { name = "psycopg" }, + { name = "psycopg", extra = ["binary"] }, { name = "python-dotenv" }, { name = "python-multipart" }, { name = "sqlmodel" }, @@ -579,7 +579,7 @@ requires-dist = [ { name = "alembic", specifier = ">=1.14" }, { name = "fastapi", specifier = ">=0.132.0" }, { name = "google-genai", specifier = ">=1.65.0" }, - { name = "psycopg", specifier = ">=3.3.3" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.3.3" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "python-multipart", specifier = ">=0.0.22" }, { name = "sqlmodel", specifier = ">=0.0.37" }, @@ -739,6 +739,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, ] +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" }, + { url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" }, + { url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" }, + { url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" }, + { url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" }, + { url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" }, + { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" }, + { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" }, + { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" }, + { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" }, + { url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" }, + { url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" }, + { url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" }, + { url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" }, + { url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, +] + [[package]] name = "pyasn1" version = "0.6.2" diff --git a/docs/frontend-design-cookbook.md b/docs/frontend-design-cookbook.md new file mode 100644 index 0000000..e916f3d --- /dev/null +++ b/docs/frontend-design-cookbook.md @@ -0,0 +1,189 @@ +# Frontend Design Cookbook + +This cookbook defines the visual system for the frontend so every new change extends the existing style instead of inventing a new one. + +## Design intent + +- Core tone: light editorial, calm and information-first. +- Product feel: premium personal logbook, not generic SaaS dashboard. +- Contrast model: neutral paper and ink do most of the work; accents are restrained. + +## Non-negotiables + +- Keep layouts readable first. Aesthetic details support hierarchy, not the other way around. +- Use one visual language across the app shell, cards, forms, tables, and actions. +- Prefer subtle depth (borders, layered paper, soft shadows) over loud gradients. +- Keep motion purposeful and short; always preserve `prefers-reduced-motion` behavior. + +## Typography + +- Display/headings: `Cormorant Infant`. +- Body/UI text: `Manrope`. +- Use display typography for page titles and section heads only. +- Keep paragraph text in body font for legibility. + +## Color system + +Global neutrals are defined in `frontend/src/app.css` using CSS variables. + +- `--background`, `--card`, `--foreground`, `--muted-foreground`, `--border` +- `--page-accent` drives route-level emphasis. +- `--page-accent-soft` is the low-contrast companion tint. + +### Domain accents (muted and professional) + +- Dashboard: `--accent-dashboard` +- Products: `--accent-products` +- Routines: `--accent-routines` +- Skin: `--accent-skin` +- Health labs: `--accent-health-labs` +- Health medications: `--accent-health-meds` + +The app shell assigns a domain class per route and maps it to `--page-accent`. + +## Where to use accent color + +Use accent for: + +- active navigation state +- important buttons and key badges +- focus ring tint and hover border tint +- section separators and small status markers + +Do not use accent for: + +- full-page backgrounds +- body text +- large surfaces that reduce readability + +Guideline: accent should occupy roughly 10-15% of visual area per screen. + +## Layout rules + +- Use the app shell spacing rhythm from `app.css` (`.app-main`, editorial cards). +- Keep max content width constrained for readability. +- Prefer asymmetry in hero/summary areas, but keep forms and dense data grids regular. + +### Shared page wrappers + +Use these wrappers before introducing route-specific structure: + +- `editorial-page`: standard constrained content width for route pages. +- `editorial-hero`: top summary strip for title, subtitle, and primary actions. +- `editorial-panel`: primary surface for forms, tables, and ledgers. +- `editorial-toolbar`: compact action row under hero copy. +- `editorial-backlink`: standard top-left back navigation style. +- `editorial-alert`, `editorial-alert--error`, `editorial-alert--success`: feedback banners. + +## Component rules + +- Reuse UI primitives under `frontend/src/lib/components/ui/*`. +- Keep primitive APIs stable when changing visual treatment. +- Style via tokens and shared classes, not one-off hardcoded colors. +- New variants must be documented in this file. + +### Existing shared utility patterns + +These classes are already in use and should be reused: + +- Lists and ledgers: `routine-ledger-row`, `products-mobile-card`, `health-entry-row` +- Group headers: `products-section-title` +- Table shell: `products-table-shell` +- Tabs shell: `products-tabs`, `editorial-tabs` +- Health semantic pills: `health-kind-pill*`, `health-flag-pill*` + +## Forms and data views + +- Inputs should remain high-contrast and calm. +- Validation/error states should be explicit and never color-only. +- Tables and dense lists should prioritize scanning: spacing, row separators, concise metadata. + +### DRY form primitives + +- Use shared form components for repeated native select markup: + - `frontend/src/lib/components/forms/SimpleSelect.svelte` + - `frontend/src/lib/components/forms/GroupedSelect.svelte` +- Use shared checkbox helper for repeated label+hint toggles: + - `frontend/src/lib/components/forms/HintCheckbox.svelte` +- Use shared input field helper for repeated label+input rows: + - `frontend/src/lib/components/forms/LabeledInputField.svelte` +- Use shared section card helper for repeated titled form panels: + - `frontend/src/lib/components/forms/FormSectionCard.svelte` +- Use shared class tokens from: + - `frontend/src/lib/components/forms/form-classes.ts` +- Prefer passing option labels from route files via `m.*` to keep i18n explicit. + +### Select policy (performance + maintainability) + +- Default to native ` - {#if aiError} -

{aiError}

- {/if} - - - {/if} - + - - - {m["productForm_basicInfo"]()} - -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
+ + + {m["productForm_basicInfo"]()} + {m["productForm_ingredients"]()} + {m["productForm_effectProfile"]()} + {m["productForm_productDetails"]()} + {m["productForm_personalNotes"]()} + + - - - {m["productForm_classification"]()} - -
-
- - - -
+{#if showAiTrigger} +
+ +
+{/if} -
- - - -
+{#if aiModalOpen} + {#await import('$lib/components/ProductFormAiModal.svelte') then mod} + {@const AiModal = mod.default} + + {/await} +{/if} -
- - - -
+{#await import('$lib/components/product-form/ProductFormBasicSection.svelte') then mod} + {@const BasicSection = mod.default} + +{/await} -
- - - -
- -
- - - -
-
-
-
+{#await import('$lib/components/product-form/ProductFormClassificationSection.svelte') then mod} + {@const ClassificationSection = mod.default} + +{/await} - + {m["productForm_skinProfile"]()}
- {#each skinTypes as st} + {#each skinTypes as st (st)}