refactor(products): remove obsolete interaction fields across stack

This commit is contained in:
Piotr Oleszczyk 2026-03-04 12:42:12 +01:00
parent 1d8a8eafb8
commit c5ea38880c
16 changed files with 32 additions and 278 deletions

View file

@ -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))

View file

@ -38,7 +38,6 @@ from innercontext.models.product import (
ActiveIngredient, ActiveIngredient,
ProductContext, ProductContext,
ProductEffectProfile, ProductEffectProfile,
ProductInteraction,
) )
router = APIRouter() router = APIRouter()
@ -91,8 +90,6 @@ class ProductUpdate(SQLModel):
ph_min: Optional[float] = None ph_min: Optional[float] = None
ph_max: 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 context_rules: Optional[ProductContext] = None
min_interval_hours: Optional[int] = None min_interval_hours: Optional[int] = None
@ -140,8 +137,6 @@ class ProductParseResponse(SQLModel):
product_effect_profile: Optional[ProductEffectProfile] = None product_effect_profile: Optional[ProductEffectProfile] = None
ph_min: Optional[float] = None ph_min: Optional[float] = None
ph_max: 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 context_rules: Optional[ProductContext] = None
min_interval_hours: Optional[int] = None min_interval_hours: Optional[int] = None
max_frequency_per_week: Optional[int] = None max_frequency_per_week: Optional[int] = None
@ -347,8 +342,6 @@ actives[].functions (array, pick applicable):
actives[].strength_level: 1 (low) | 2 (medium) | 3 (high) actives[].strength_level: 1 (low) | 2 (medium) | 3 (high)
actives[].irritation_potential: 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): OUTPUT SCHEMA (all fields optional omit what you cannot determine):
{ {
"name": string, "name": string,
@ -402,10 +395,6 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine):
}, },
"ph_min": number, "ph_min": number,
"ph_max": number, "ph_max": number,
"incompatible_with": [
{"target": string, "scope": string, "reason": string}
],
"synergizes_with": [string, ...],
"context_rules": { "context_rules": {
"safe_after_shaving": boolean, "safe_after_shaving": boolean,
"safe_after_acids": boolean, "safe_after_acids": boolean,
@ -721,7 +710,6 @@ def _build_safety_rules_tool_handler(products: list[Product]):
return { return {
"id": pid, "id": pid,
"name": product.name, "name": product.name,
"incompatible_with": (ctx.get("incompatible_with") or [])[:24],
"contraindications": (ctx.get("contraindications") or [])[:24], "contraindications": (ctx.get("contraindications") or [])[:24],
"context_rules": ctx.get("context_rules") or {}, "context_rules": ctx.get("context_rules") or {},
"safety": ctx.get("safety") or {}, "safety": ctx.get("safety") or {},
@ -755,7 +743,7 @@ _SAFETY_RULES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
name="get_product_safety_rules", name="get_product_safety_rules",
description=( description=(
"Return safety and compatibility rules for selected product UUIDs, " "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( parameters=genai_types.Schema(
type=genai_types.Type.OBJECT, type=genai_types.Type.OBJECT,

View file

@ -378,8 +378,6 @@ def _build_products_context(
notable = {k: v for k, v in profile.items() if v and v > 0} notable = {k: v for k, v in profile.items() if v and v > 0}
if notable: if notable:
entry += f" effects={notable}" entry += f" effects={notable}"
if ctx.get("incompatible_with"):
entry += f" incompatible_with={ctx['incompatible_with']}"
if ctx.get("contraindications"): if ctx.get("contraindications"):
entry += f" contraindications={ctx['contraindications']}" entry += f" contraindications={ctx['contraindications']}"
if ctx.get("context_rules"): if ctx.get("context_rules"):
@ -501,7 +499,6 @@ def _build_safety_rules_tool_handler(
return { return {
"id": pid, "id": pid,
"name": product.name, "name": product.name,
"incompatible_with": (ctx.get("incompatible_with") or [])[:24],
"contraindications": (ctx.get("contraindications") or [])[:24], "contraindications": (ctx.get("contraindications") or [])[:24],
"context_rules": ctx.get("context_rules") or {}, "context_rules": ctx.get("context_rules") or {},
"safety": ctx.get("safety") or {}, "safety": ctx.get("safety") or {},
@ -630,7 +627,7 @@ _SAFETY_RULES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
name="get_product_safety_rules", name="get_product_safety_rules",
description=( description=(
"Return safety and compatibility rules for selected product UUIDs: " "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( parameters=genai_types.Schema(
type=genai_types.Type.OBJECT, type=genai_types.Type.OBJECT,
@ -688,8 +685,7 @@ WYMAGANIA ODPOWIEDZI:
ZASADY PLANOWANIA: ZASADY PLANOWANIA:
- Kolejność warstw: cleanser -> toner -> essence -> serum -> moisturizer -> [SPF dla AM]. - Kolejność warstw: cleanser -> toner -> essence -> serum -> moisturizer -> [SPF dla AM].
- Respektuj: incompatible_with (same_step / same_day / same_period), context_rules, - Respektuj: context_rules, min_interval_hours, max_frequency_per_week, usage_notes.
min_interval_hours, max_frequency_per_week, usage_notes.
- Zarządzanie inwentarzem: - Zarządzanie inwentarzem:
- Preferuj produkty już otwarte (miękka preferencja). - Preferuj produkty już otwarte (miękka preferencja).
- Unikaj funkcjonalnej redundancji (np. wielokrotne źródła panthenolu, ceramidów lub niacynamidu w tej samej rutynie), - Unikaj funkcjonalnej redundancji (np. wielokrotne źródła panthenolu, ceramidów lub niacynamidu w tej samej rutynie),

View file

@ -7,7 +7,6 @@ from .enums import (
EvidenceLevel, EvidenceLevel,
GroomingAction, GroomingAction,
IngredientFunction, IngredientFunction,
InteractionScope,
MedicationKind, MedicationKind,
OverallSkinState, OverallSkinState,
PartOfDay, PartOfDay,
@ -29,7 +28,6 @@ from .product import (
ProductBase, ProductBase,
ProductContext, ProductContext,
ProductEffectProfile, ProductEffectProfile,
ProductInteraction,
ProductInventory, ProductInventory,
ProductPublic, ProductPublic,
ProductWithInventory, ProductWithInventory,
@ -53,7 +51,6 @@ __all__ = [
"EvidenceLevel", "EvidenceLevel",
"GroomingAction", "GroomingAction",
"IngredientFunction", "IngredientFunction",
"InteractionScope",
"MedicationKind", "MedicationKind",
"OverallSkinState", "OverallSkinState",
"PartOfDay", "PartOfDay",
@ -77,7 +74,6 @@ __all__ = [
"ProductBase", "ProductBase",
"ProductContext", "ProductContext",
"ProductEffectProfile", "ProductEffectProfile",
"ProductInteraction",
"ProductInventory", "ProductInventory",
"ProductPublic", "ProductPublic",
"ProductWithInventory", "ProductWithInventory",

View file

@ -131,12 +131,6 @@ class EvidenceLevel(str, Enum):
HIGH = "high" HIGH = "high"
class InteractionScope(str, Enum):
SAME_STEP = "same_step"
SAME_DAY = "same_day"
SAME_PERIOD = "same_period"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Health # Health
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -12,7 +12,6 @@ from .enums import (
AbsorptionSpeed, AbsorptionSpeed,
DayTime, DayTime,
IngredientFunction, IngredientFunction,
InteractionScope,
PriceTier, PriceTier,
ProductCategory, ProductCategory,
SkinConcern, SkinConcern,
@ -57,12 +56,6 @@ class ActiveIngredient(SQLModel):
irritation_potential: StrengthLevel | None = None irritation_potential: StrengthLevel | None = None
class ProductInteraction(SQLModel):
target: str
scope: InteractionScope
reason: str | None = None
class ProductContext(SQLModel): class ProductContext(SQLModel):
safe_after_shaving: bool | None = None safe_after_shaving: bool | None = None
safe_after_acids: bool | None = None safe_after_acids: bool | None = None
@ -128,8 +121,6 @@ class ProductBase(SQLModel):
ph_min: float | None = Field(default=None, ge=0, le=14) ph_min: float | None = Field(default=None, ge=0, le=14)
ph_max: 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 context_rules: ProductContext | None = None
min_interval_hours: int | None = Field(default=None, ge=0) min_interval_hours: int | None = Field(default=None, ge=0)
@ -181,12 +172,6 @@ class Product(ProductBase, table=True):
sa_column=Column(JSON, nullable=False), 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( context_rules: ProductContext | None = Field(
default=None, sa_column=Column(JSON, nullable=True) default=None, sa_column=Column(JSON, nullable=True)
) )
@ -302,26 +287,6 @@ class Product(ProductBase, table=True):
if nonzero: if nonzero:
ctx["effect_profile"] = 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: if self.context_rules is not None:
cr = self.context_rules cr = self.context_rules
if isinstance(cr, dict): if isinstance(cr, dict):

View file

@ -7,14 +7,12 @@ from innercontext.models import Product
from innercontext.models.enums import ( from innercontext.models.enums import (
DayTime, DayTime,
IngredientFunction, IngredientFunction,
InteractionScope,
ProductCategory, ProductCategory,
) )
from innercontext.models.product import ( from innercontext.models.product import (
ActiveIngredient, ActiveIngredient,
ProductContext, ProductContext,
ProductEffectProfile, ProductEffectProfile,
ProductInteraction,
) )
@ -156,29 +154,6 @@ def test_effect_profile_nonzero_included():
assert "retinoid_strength" not in ctx["effect_profile"] 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 # Context rules
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -136,7 +136,6 @@ def test_shopping_tool_handlers_return_payloads(session: Session):
usage_notes="Use AM and PM on clean skin.", usage_notes="Use AM and PM on clean skin.",
inci=["Water", "Niacinamide"], inci=["Water", "Niacinamide"],
actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}], actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}],
incompatible_with=[{"target": "Vitamin C", "scope": "same_step"}],
context_rules={"safe_after_shaving": True}, context_rules={"safe_after_shaving": True},
product_effect_profile={}, 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." assert notes_data["products"][0]["usage_notes"] == "Use AM and PM on clean skin."
safety_data = _build_safety_rules_tool_handler([product])(payload) 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] assert "context_rules" in safety_data["products"][0]

View file

@ -184,7 +184,6 @@ def test_build_products_context(session: Session):
recommended_time="am", recommended_time="am",
pao_months=6, pao_months=6,
product_effect_profile={"hydration_immediate": 2, "exfoliation_strength": 0}, product_effect_profile={"hydration_immediate": 2, "exfoliation_strength": 0},
incompatible_with=[{"target": "retinol", "scope": "same_routine"}],
context_rules={"safe_after_shaving": False}, context_rules={"safe_after_shaving": False},
min_interval_hours=12, min_interval_hours=12,
max_frequency_per_week=7, max_frequency_per_week=7,
@ -233,7 +232,6 @@ def test_build_products_context(session: Session):
assert "nearest_open_pao_deadline=" in ctx assert "nearest_open_pao_deadline=" in ctx
assert "pao_months=6" in ctx assert "pao_months=6" in ctx
assert "effects={'hydration_immediate': 2}" 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 "context_rules={'safe_after_shaving': False}" in ctx
assert "min_interval_hours=12" in ctx assert "min_interval_hours=12" in ctx
assert "max_frequency_per_week=7" 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, leave_on=True,
usage_notes="Apply morning and evening.", usage_notes="Apply morning and evening.",
actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}], actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}],
incompatible_with=[{"target": "Retinol", "scope": "same_step"}],
context_rules={"safe_after_shaving": True}, context_rules={"safe_after_shaving": True},
product_effect_profile={}, 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." assert notes_out["products"][0]["usage_notes"] == "Apply morning and evening."
safety_out = _build_safety_rules_tool_handler([p])(ids_payload) 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] assert "context_rules" in safety_out["products"][0]

View file

@ -379,16 +379,6 @@
"productForm_activeIrritation": "Irritation", "productForm_activeIrritation": "Irritation",
"productForm_activeFunctions": "Functions", "productForm_activeFunctions": "Functions",
"productForm_effectProfile": "Effect profile (05)", "productForm_effectProfile": "Effect profile (05)",
"productForm_interactions": "Interactions",
"productForm_synergizesWith": "Synergizes with (one per line)",
"productForm_incompatibleWith": "Incompatible with",
"productForm_addIncompatibility": "+ Add incompatibility",
"productForm_noIncompatibilities": "No incompatibilities added.",
"productForm_incompTarget": "Target ingredient",
"productForm_incompScope": "Scope",
"productForm_incompReason": "Reason (optional)",
"productForm_incompReasonPlaceholder": "e.g. reduces efficacy",
"productForm_incompScopeSelect": "Select…",
"productForm_contextRules": "Context rules", "productForm_contextRules": "Context rules",
"productForm_ctxAfterShaving": "Safe after shaving", "productForm_ctxAfterShaving": "Safe after shaving",
"productForm_ctxAfterAcids": "Safe after acids", "productForm_ctxAfterAcids": "Safe after acids",
@ -495,10 +485,6 @@
"productForm_fnVitaminC": "vitamin C", "productForm_fnVitaminC": "vitamin C",
"productForm_fnAntiAging": "anti-aging", "productForm_fnAntiAging": "anti-aging",
"productForm_scopeSameStep": "same step",
"productForm_scopeSameDay": "same day",
"productForm_scopeSamePeriod": "same period",
"productForm_strengthLow": "1 Low", "productForm_strengthLow": "1 Low",
"productForm_strengthMedium": "2 Medium", "productForm_strengthMedium": "2 Medium",
"productForm_strengthHigh": "3 High", "productForm_strengthHigh": "3 High",

View file

@ -393,16 +393,6 @@
"productForm_activeIrritation": "Podrażnienie", "productForm_activeIrritation": "Podrażnienie",
"productForm_activeFunctions": "Funkcje", "productForm_activeFunctions": "Funkcje",
"productForm_effectProfile": "Profil działania (05)", "productForm_effectProfile": "Profil działania (05)",
"productForm_interactions": "Interakcje",
"productForm_synergizesWith": "Synergizuje z (jedno na linię)",
"productForm_incompatibleWith": "Niekompatybilny z",
"productForm_addIncompatibility": "+ Dodaj niekompatybilność",
"productForm_noIncompatibilities": "Brak niekompatybilności.",
"productForm_incompTarget": "Składnik docelowy",
"productForm_incompScope": "Zakres",
"productForm_incompReason": "Powód (opcjonalny)",
"productForm_incompReasonPlaceholder": "np. zmniejsza skuteczność",
"productForm_incompScopeSelect": "Wybierz…",
"productForm_contextRules": "Reguły kontekstu", "productForm_contextRules": "Reguły kontekstu",
"productForm_ctxAfterShaving": "Bezpieczny po goleniu", "productForm_ctxAfterShaving": "Bezpieczny po goleniu",
"productForm_ctxAfterAcids": "Bezpieczny po kwasach", "productForm_ctxAfterAcids": "Bezpieczny po kwasach",
@ -509,10 +499,6 @@
"productForm_fnVitaminC": "witamina C", "productForm_fnVitaminC": "witamina C",
"productForm_fnAntiAging": "przeciwstarzeniowy", "productForm_fnAntiAging": "przeciwstarzeniowy",
"productForm_scopeSameStep": "ten sam krok",
"productForm_scopeSameDay": "ten sam dzień",
"productForm_scopeSamePeriod": "ten sam okres",
"productForm_strengthLow": "1 Niskie", "productForm_strengthLow": "1 Niskie",
"productForm_strengthMedium": "2 Średnie", "productForm_strengthMedium": "2 Średnie",
"productForm_strengthHigh": "3 Wysokie", "productForm_strengthHigh": "3 Wysokie",

View file

@ -11,7 +11,6 @@ import type {
Product, Product,
ProductContext, ProductContext,
ProductEffectProfile, ProductEffectProfile,
ProductInteraction,
ProductInventory, ProductInventory,
Routine, Routine,
RoutineSuggestion, RoutineSuggestion,
@ -127,8 +126,6 @@ export interface ProductParseResponse {
product_effect_profile?: ProductEffectProfile; product_effect_profile?: ProductEffectProfile;
ph_min?: number; ph_min?: number;
ph_max?: number; ph_max?: number;
incompatible_with?: ProductInteraction[];
synergizes_with?: string[];
context_rules?: ProductContext; context_rules?: ProductContext;
min_interval_hours?: number; min_interval_hours?: number;
max_frequency_per_week?: number; max_frequency_per_week?: number;

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import type { Product } from '$lib/types'; import type { Product } from '$lib/types';
import type { IngredientFunction, InteractionScope } from '$lib/types'; import type { IngredientFunction } from '$lib/types';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
@ -34,7 +34,6 @@
'brightening', 'anti_acne', 'ceramide', 'niacinamide', 'brightening', 'anti_acne', 'ceramide', 'niacinamide',
'sunscreen', 'peptide', 'hair_growth_stimulant', 'prebiotic', 'vitamin_c', 'anti_aging' 'sunscreen', 'peptide', 'hair_growth_stimulant', 'prebiotic', 'vitamin_c', 'anti_aging'
]; ];
const interactionScopes: InteractionScope[] = ['same_step', 'same_day', 'same_period'];
// ── Translated label maps ───────────────────────────────────────────────── // ── Translated label maps ─────────────────────────────────────────────────
@ -125,12 +124,6 @@
anti_aging: m["productForm_fnAntiAging"]() anti_aging: m["productForm_fnAntiAging"]()
}); });
const scopeLabels = $derived<Record<string, string>>({
same_step: m["productForm_scopeSameStep"](),
same_day: m["productForm_scopeSameDay"](),
same_period: m["productForm_scopeSamePeriod"]()
});
const tristate = $derived([ const tristate = $derived([
{ value: '', label: m.common_unknown() }, { value: '', label: m.common_unknown() },
{ value: 'true', label: m.common_yes() }, { value: 'true', label: m.common_yes() },
@ -177,7 +170,6 @@
let usageNotes = $state(untrack(() => product?.usage_notes ?? '')); let usageNotes = $state(untrack(() => product?.usage_notes ?? ''));
let inciText = $state(untrack(() => product?.inci?.join('\n') ?? '')); let inciText = $state(untrack(() => product?.inci?.join('\n') ?? ''));
let contraindicationsText = $state(untrack(() => product?.contraindications?.join('\n') ?? '')); let contraindicationsText = $state(untrack(() => product?.contraindications?.join('\n') ?? ''));
let synergizesWithText = $state(untrack(() => product?.synergizes_with?.join('\n') ?? ''));
let recommendedFor = $state<string[]>(untrack(() => [...(product?.recommended_for ?? [])])); let recommendedFor = $state<string[]>(untrack(() => [...(product?.recommended_for ?? [])]));
let targetConcerns = $state<string[]>(untrack(() => [...(product?.targets ?? [])])); let targetConcerns = $state<string[]>(untrack(() => [...(product?.targets ?? [])]));
@ -239,7 +231,6 @@
if (r.is_tool != null) isTool = r.is_tool; if (r.is_tool != null) isTool = r.is_tool;
if (r.inci?.length) inciText = r.inci.join('\n'); if (r.inci?.length) inciText = r.inci.join('\n');
if (r.contraindications?.length) contraindicationsText = r.contraindications.join('\n'); if (r.contraindications?.length) contraindicationsText = r.contraindications.join('\n');
if (r.synergizes_with?.length) synergizesWithText = r.synergizes_with.join('\n');
if (r.actives?.length) { if (r.actives?.length) {
actives = r.actives.map((a) => ({ actives = r.actives.map((a) => ({
name: a.name, name: a.name,
@ -249,13 +240,6 @@
irritation_potential: a.irritation_potential != null ? String(a.irritation_potential) : '' irritation_potential: a.irritation_potential != null ? String(a.irritation_potential) : ''
})); }));
} }
if (r.incompatible_with?.length) {
incompatibleWith = r.incompatible_with.map((i) => ({
target: i.target,
scope: i.scope,
reason: i.reason ?? ''
}));
}
if (r.product_effect_profile) { if (r.product_effect_profile) {
effectValues = { ...effectValues, ...r.product_effect_profile }; effectValues = { ...effectValues, ...r.product_effect_profile };
} }
@ -380,40 +364,6 @@
) )
); );
// ── Dynamic incompatible_with ─────────────────────────────────────────────
type IncompatibleRow = { target: string; scope: string; reason: string };
let incompatibleWith: IncompatibleRow[] = $state(
untrack(() =>
product?.incompatible_with?.map((i) => ({
target: i.target,
scope: i.scope,
reason: i.reason ?? ''
})) ?? []
)
);
function addIncompatible() {
incompatibleWith = [...incompatibleWith, { target: '', scope: '', reason: '' }];
}
function removeIncompatible(i: number) {
incompatibleWith = incompatibleWith.filter((_, idx) => idx !== i);
}
let incompatibleJson = $derived(
JSON.stringify(
incompatibleWith
.filter((i) => i.target.trim() && i.scope)
.map((i) => ({
target: i.target.trim(),
scope: i.scope,
...(i.reason.trim() ? { reason: i.reason.trim() } : {})
}))
)
);
const textareaClass = const textareaClass =
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'; 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
@ -749,68 +699,6 @@
</CardContent> </CardContent>
</Card> </Card>
<!-- ── Interactions ───────────────────────────────────────────────────────── -->
<Card>
<CardHeader><CardTitle>{m["productForm_interactions"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="synergizes_with">{m["productForm_synergizesWith"]()}</Label>
<textarea
id="synergizes_with"
name="synergizes_with"
rows="3"
placeholder="Ceramides&#10;Niacinamide&#10;Retinoids"
class={textareaClass}
bind:value={synergizesWithText}
></textarea>
</div>
<div class="space-y-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Label>{m["productForm_incompatibleWith"]()}</Label>
<Button type="button" variant="outline" size="sm" onclick={addIncompatible}>
{m["productForm_addIncompatibility"]()}
</Button>
</div>
<input type="hidden" name="incompatible_with_json" value={incompatibleJson} />
{#each incompatibleWith as row, i}
<div class="grid grid-cols-2 gap-2 items-end sm:grid-cols-[1fr_140px_1fr_auto]">
<div class="space-y-1">
<Label class="text-xs">{m["productForm_incompTarget"]()}</Label>
<Input placeholder="e.g. Vitamin C" bind:value={row.target} />
</div>
<div class="space-y-1">
<Label class="text-xs">{m["productForm_incompScope"]()}</Label>
<select class={selectClass} bind:value={row.scope}>
<option value="">{m["productForm_incompScopeSelect"]()}</option>
{#each interactionScopes as s}
<option value={s}>{scopeLabels[s]}</option>
{/each}
</select>
</div>
<div class="space-y-1">
<Label class="text-xs">{m["productForm_incompReason"]()}</Label>
<Input placeholder={m["productForm_incompReasonPlaceholder"]()} bind:value={row.reason} />
</div>
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => removeIncompatible(i)}
class="text-destructive hover:text-destructive"
>✕</Button>
</div>
{/each}
{#if incompatibleWith.length === 0}
<p class="text-sm text-muted-foreground">{m["productForm_noIncompatibilities"]()}</p>
{/if}
</div>
</CardContent>
</Card>
<!-- ── Context rules ──────────────────────────────────────────────────────── --> <!-- ── Context rules ──────────────────────────────────────────────────────── -->
<Card> <Card>
<CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader>

View file

@ -33,7 +33,6 @@ export type IngredientFunction =
| "prebiotic" | "prebiotic"
| "vitamin_c" | "vitamin_c"
| "anti_aging"; | "anti_aging";
export type InteractionScope = "same_step" | "same_day" | "same_period";
export type MedicationKind = export type MedicationKind =
| "prescription" | "prescription"
| "otc" | "otc"
@ -113,12 +112,6 @@ export interface ProductEffectProfile {
anti_aging_strength: number; anti_aging_strength: number;
} }
export interface ProductInteraction {
target: string;
scope: InteractionScope;
reason?: string;
}
export interface ProductContext { export interface ProductContext {
safe_after_shaving?: boolean; safe_after_shaving?: boolean;
safe_after_acids?: boolean; safe_after_acids?: boolean;
@ -172,8 +165,6 @@ export interface Product {
product_effect_profile: ProductEffectProfile; product_effect_profile: ProductEffectProfile;
ph_min?: number; ph_min?: number;
ph_max?: number; ph_max?: number;
incompatible_with?: ProductInteraction[];
synergizes_with?: string[];
context_rules?: ProductContext; context_rules?: ProductContext;
min_interval_hours?: number; min_interval_hours?: number;
max_frequency_per_week?: number; max_frequency_per_week?: number;

View file

@ -166,23 +166,6 @@ export const actions: Actions = {
body.actives = null; body.actives = null;
} }
// Incompatible with
try {
const raw = form.get('incompatible_with_json') as string | null;
if (raw) {
const parsed = JSON.parse(raw);
body.incompatible_with = Array.isArray(parsed) && parsed.length > 0 ? parsed : null;
} else {
body.incompatible_with = null;
}
} catch {
body.incompatible_with = null;
}
// Synergizes with
const synergizes = parseTextList(form.get('synergizes_with') as string | null);
body.synergizes_with = synergizes.length > 0 ? synergizes : null;
// Context rules // Context rules
body.context_rules = parseContextRules(form) ?? null; body.context_rules = parseContextRules(form) ?? null;

View file

@ -167,19 +167,6 @@ export const actions: Actions = {
} }
} catch { /* ignore malformed JSON */ } } catch { /* ignore malformed JSON */ }
// Incompatible with (JSON array)
try {
const raw = form.get('incompatible_with_json') as string | null;
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) payload.incompatible_with = parsed;
}
} catch { /* ignore */ }
// Synergizes with
const synergizes = parseTextList(form.get('synergizes_with') as string | null);
if (synergizes.length > 0) payload.synergizes_with = synergizes;
// Context rules // Context rules
const contextRules = parseContextRules(form); const contextRules = parseContextRules(form);
if (contextRules) payload.context_rules = contextRules; if (contextRules) payload.context_rules = contextRules;