diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index 801d21f..fda0a79 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -21,11 +21,8 @@ from innercontext.models.product import ( from innercontext.models.enums import ( AbsorptionSpeed, DayTime, - EvidenceLevel, PriceTier, - RoutineRole, TextureType, - UsageFrequency, SkinType, ) @@ -46,7 +43,6 @@ class ProductCreate(SQLModel): barcode: Optional[str] = None category: ProductCategory - routine_role: RoutineRole recommended_time: DayTime texture: Optional[TextureType] = None @@ -61,13 +57,10 @@ class ProductCreate(SQLModel): actives: Optional[list[ActiveIngredient]] = None recommended_for: list[SkinType] = [] - recommended_frequency: Optional[UsageFrequency] = None targets: list[SkinConcern] = [] contraindications: list[str] = [] usage_notes: Optional[str] = None - evidence_level: Optional[EvidenceLevel] = None - claims: list[str] = [] fragrance_free: Optional[bool] = None essential_oils_free: Optional[bool] = None @@ -104,7 +97,6 @@ class ProductUpdate(SQLModel): barcode: Optional[str] = None category: Optional[ProductCategory] = None - routine_role: Optional[RoutineRole] = None recommended_time: Optional[DayTime] = None texture: Optional[TextureType] = None @@ -119,13 +111,10 @@ class ProductUpdate(SQLModel): actives: Optional[list[ActiveIngredient]] = None recommended_for: Optional[list[SkinType]] = None - recommended_frequency: Optional[UsageFrequency] = None targets: Optional[list[SkinConcern]] = None contraindications: Optional[list[str]] = None usage_notes: Optional[str] = None - evidence_level: Optional[EvidenceLevel] = None - claims: Optional[list[str]] = None fragrance_free: Optional[bool] = None essential_oils_free: Optional[bool] = None diff --git a/backend/innercontext/models/product.py b/backend/innercontext/models/product.py index 6905bfa..961f9a8 100644 --- a/backend/innercontext/models/product.py +++ b/backend/innercontext/models/product.py @@ -11,17 +11,14 @@ from .domain import Domain from .enums import ( AbsorptionSpeed, DayTime, - EvidenceLevel, IngredientFunction, InteractionScope, PriceTier, ProductCategory, - RoutineRole, SkinConcern, SkinType, StrengthLevel, TextureType, - UsageFrequency, ) @@ -60,8 +57,6 @@ class ActiveIngredient(SQLModel): strength_level: StrengthLevel | None = None irritation_potential: StrengthLevel | None = None - cumulative_with: list[IngredientFunction] | None = None - class ProductInteraction(SQLModel): target: str @@ -106,7 +101,6 @@ class Product(SQLModel, table=True): barcode: str | None = Field(default=None, max_length=64) category: ProductCategory - routine_role: RoutineRole recommended_time: DayTime texture: TextureType | None = None @@ -127,7 +121,6 @@ class Product(SQLModel, table=True): recommended_for: list[SkinType] = Field( default_factory=list, sa_column=Column(JSON, nullable=False) ) - recommended_frequency: UsageFrequency | None = None targets: list[SkinConcern] = Field( default_factory=list, sa_column=Column(JSON, nullable=False) @@ -136,10 +129,6 @@ class Product(SQLModel, table=True): default_factory=list, sa_column=Column(JSON, nullable=False) ) usage_notes: str | None = None - evidence_level: EvidenceLevel | None = Field(default=None, index=True) - claims: list[str] = Field( - default_factory=list, sa_column=Column(JSON, nullable=False) - ) fragrance_free: bool | None = None essential_oils_free: bool | None = None @@ -221,12 +210,11 @@ class Product(SQLModel, table=True): "name": self.name, "brand": self.brand, "category": _ev(self.category), - "routine_role": _ev(self.routine_role), "recommended_time": _ev(self.recommended_time), "leave_on": self.leave_on, } - for field in ("line_name", "sku", "url", "barcode"): + for field in ("line_name", "url"): val = getattr(self, field) if val is not None: ctx[field] = val @@ -241,11 +229,6 @@ class Product(SQLModel, table=True): ctx["size_ml"] = self.size_ml if self.pao_months is not None: ctx["pao_months"] = self.pao_months - if self.recommended_frequency is not None: - ctx["recommended_frequency"] = _ev(self.recommended_frequency) - if self.evidence_level is not None: - ctx["evidence_level"] = _ev(self.evidence_level) - if self.inci: ctx["inci"] = self.inci if self.recommended_for: @@ -254,8 +237,6 @@ class Product(SQLModel, table=True): ctx["targets"] = [_ev(s) for s in self.targets] if self.contraindications: ctx["contraindications"] = self.contraindications - if self.claims: - ctx["claims"] = self.claims if self.actives: actives_ctx = [] @@ -270,8 +251,6 @@ class Product(SQLModel, table=True): a_dict["functions"] = [_ev(f) for f in a.functions] if a.strength_level is not None: a_dict["strength_level"] = a.strength_level.name.lower() - if a.cumulative_with: - a_dict["cumulative_with"] = [_ev(f) for f in a.cumulative_with] actives_ctx.append(a_dict) ctx["actives"] = actives_ctx @@ -288,9 +267,9 @@ class Product(SQLModel, table=True): ep = self.product_effect_profile if ep is not None: if isinstance(ep, dict): - nonzero = {k: v for k, v in ep.items() if v} + nonzero = {k: v for k, v in ep.items() if v >= 2} else: - nonzero = {k: v for k, v in ep.model_dump().items() if v} + nonzero = {k: v for k, v in ep.model_dump().items() if v >= 2} if nonzero: ctx["effect_profile"] = nonzero diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index c2a5721..7085b78 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -53,7 +53,6 @@ def product_data(): "name": "CeraVe Moisturising Cream", "brand": "CeraVe", "category": "moisturizer", - "routine_role": "seal", "recommended_time": "both", "leave_on": True, } diff --git a/backend/tests/test_product_model.py b/backend/tests/test_product_model.py index a75e102..36660f1 100644 --- a/backend/tests/test_product_model.py +++ b/backend/tests/test_product_model.py @@ -9,7 +9,6 @@ from innercontext.models.enums import ( IngredientFunction, InteractionScope, ProductCategory, - RoutineRole, ) from innercontext.models.product import ( ActiveIngredient, @@ -25,7 +24,6 @@ def _make(**kwargs): name="Test", brand="B", category=ProductCategory.MOISTURIZER, - routine_role=RoutineRole.SEAL, recommended_time=DayTime.BOTH, leave_on=True, ) @@ -41,7 +39,7 @@ def _make(**kwargs): def test_always_present_keys(): p = _make() ctx = p.to_llm_context() - for key in ("id", "name", "brand", "category", "routine_role", "recommended_time", "leave_on"): + for key in ("id", "name", "brand", "category", "recommended_time", "leave_on"): assert key in ctx, f"Expected '{key}' in to_llm_context() output" @@ -53,17 +51,17 @@ def test_always_present_keys(): def test_optional_string_fields_absent_when_none(): p = _make() ctx = p.to_llm_context() - for key in ("line_name", "sku", "url", "barcode"): + for key in ("line_name", "url"): assert key not in ctx, f"'{key}' should not appear when None" def test_optional_string_fields_present_when_set(): - p = _make(line_name="Hydrating", sku="CV-001", url="https://example.com", barcode="123456") + p = _make(line_name="Hydrating", url="https://example.com") ctx = p.to_llm_context() assert ctx["line_name"] == "Hydrating" - assert ctx["sku"] == "CV-001" assert ctx["url"] == "https://example.com" - assert ctx["barcode"] == "123456" + assert "sku" not in ctx + assert "barcode" not in ctx # --------------------------------------------------------------------------- diff --git a/backend/tests/test_products.py b/backend/tests/test_products.py index 098df1b..f550a69 100644 --- a/backend/tests/test_products.py +++ b/backend/tests/test_products.py @@ -52,7 +52,6 @@ def test_list_filter_category(client, client_and_data=None): # Create a moisturizer and a serum base = { "brand": "B", - "routine_role": "seal", "recommended_time": "both", "leave_on": True, } @@ -72,7 +71,6 @@ def test_list_filter_category(client, client_and_data=None): def test_list_filter_brand(client): base = { - "routine_role": "seal", "recommended_time": "both", "leave_on": True, "category": "serum", @@ -90,7 +88,6 @@ def test_list_filter_brand(client): def test_list_filter_is_medication(client): base = { "brand": "B", - "routine_role": "seal", "recommended_time": "both", "leave_on": True, "category": "serum", @@ -113,7 +110,6 @@ def test_list_filter_is_medication(client): def test_list_filter_targets(client): base = { "brand": "B", - "routine_role": "seal", "recommended_time": "both", "leave_on": True, "category": "serum", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index d225660..affc1ea 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -3,7 +3,6 @@ export type AbsorptionSpeed = 'very_fast' | 'fast' | 'moderate' | 'slow' | 'very_slow'; export type BarrierState = 'intact' | 'mildly_compromised' | 'compromised'; export type DayTime = 'am' | 'pm' | 'both'; -export type EvidenceLevel = 'low' | 'mixed' | 'moderate' | 'high'; export type GroomingAction = 'shaving_razor' | 'shaving_oneblade' | 'dermarolling'; export type IngredientFunction = | 'humectant' @@ -44,14 +43,6 @@ export type ProductCategory = | 'spot_treatment' | 'oil'; export type ResultFlag = 'N' | 'ABN' | 'POS' | 'NEG' | 'L' | 'H'; -export type RoutineRole = - | 'cleanse' - | 'prepare' - | 'treatment_active' - | 'treatment_support' - | 'seal' - | 'protect' - | 'hair_treatment'; export type SkinConcern = | 'acne' | 'rosacea' @@ -68,15 +59,6 @@ export type SkinTrend = 'improving' | 'stable' | 'worsening' | 'fluctuating'; export type SkinType = 'dry' | 'oily' | 'combination' | 'sensitive' | 'normal' | 'acne_prone'; export type StrengthLevel = 1 | 2 | 3; export type TextureType = 'watery' | 'gel' | 'emulsion' | 'cream' | 'oil' | 'balm' | 'foam' | 'fluid'; -export type UsageFrequency = - | 'daily' - | 'twice_daily' - | 'every_other_day' - | 'twice_weekly' - | 'three_times_weekly' - | 'weekly' - | 'as_needed'; - // ─── Product types ─────────────────────────────────────────────────────────── export interface ActiveIngredient { @@ -85,7 +67,6 @@ export interface ActiveIngredient { functions: IngredientFunction[]; strength_level?: StrengthLevel; irritation_potential?: StrengthLevel; - cumulative_with?: IngredientFunction[]; } export interface ProductEffectProfile { @@ -140,7 +121,6 @@ export interface Product { url?: string; barcode?: string; category: ProductCategory; - routine_role: RoutineRole; recommended_time: DayTime; texture?: TextureType; absorption_speed?: AbsorptionSpeed; @@ -151,12 +131,9 @@ export interface Product { inci: string[]; actives?: ActiveIngredient[]; recommended_for: SkinType[]; - recommended_frequency?: UsageFrequency; targets: SkinConcern[]; contraindications: string[]; usage_notes?: string; - evidence_level?: EvidenceLevel; - claims: string[]; fragrance_free?: boolean; essential_oils_free?: boolean; alcohol_denat_free?: boolean; diff --git a/frontend/src/routes/products/[id]/+page.svelte b/frontend/src/routes/products/[id]/+page.svelte index 099510a..9954d97 100644 --- a/frontend/src/routes/products/[id]/+page.svelte +++ b/frontend/src/routes/products/[id]/+page.svelte @@ -37,7 +37,6 @@
Brand{product.brand} Line{product.line_name ?? '—'} - Routine role{product.routine_role.replace(/_/g, ' ')} Time{product.recommended_time} Leave-on{product.leave_on ? 'Yes' : 'No'} Texture{product.texture ?? '—'} diff --git a/frontend/src/routes/products/new/+page.server.ts b/frontend/src/routes/products/new/+page.server.ts index 96a127d..6f887f7 100644 --- a/frontend/src/routes/products/new/+page.server.ts +++ b/frontend/src/routes/products/new/+page.server.ts @@ -6,6 +6,29 @@ export const load: PageServerLoad = async () => { return {}; }; +function parseOptionalFloat(v: string | null): number | undefined { + if (!v) return undefined; + const n = parseFloat(v); + return isNaN(n) ? undefined : n; +} + +function parseOptionalInt(v: string | null): number | undefined { + if (!v) return undefined; + const n = parseInt(v, 10); + return isNaN(n) ? undefined : n; +} + +function parseTristate(v: string | null): boolean | undefined { + if (v === 'true') return true; + if (v === 'false') return false; + return undefined; +} + +function parseOptionalString(v: string | null): string | undefined { + const s = v?.trim(); + return s || undefined; +} + export const actions: Actions = { default: async ({ request }) => { const form = await request.formData(); @@ -14,29 +37,110 @@ export const actions: Actions = { const brand = form.get('brand') as string; const category = form.get('category') as string; const recommended_time = form.get('recommended_time') as string; - const routine_role = form.get('routine_role') as string; - const leave_on = form.get('leave_on') === 'true'; - if (!name || !brand || !category || !recommended_time || !routine_role) { + if (!name || !brand || !category || !recommended_time) { return fail(400, { error: 'Required fields missing' }); } - const targets = (form.get('targets') as string) - ?.split(',') - .map((t) => t.trim()) - .filter(Boolean) ?? []; + const leave_on = form.get('leave_on') === 'true'; + + // Lists from checkboxes + const recommended_for = form.getAll('recommended_for') as string[]; + const targets = form.getAll('targets') as string[]; + + // INCI: split on newlines and commas + const inci_raw = form.get('inci') as string; + const inci = inci_raw + ? inci_raw.split(/[\n,]/).map((s) => s.trim()).filter(Boolean) + : []; + + const payload: Record = { + name, + brand, + category, + recommended_time, + leave_on, + recommended_for, + targets, + inci + }; + + // Optional strings + const optStrings: Array<[string, string]> = [ + ['line_name', 'line_name'], + ['url', 'url'], + ['sku', 'sku'], + ['barcode', 'barcode'], + ['usage_notes', 'usage_notes'], + ['personal_tolerance_notes', 'personal_tolerance_notes'] + ]; + for (const [field, key] of optStrings) { + const v = parseOptionalString(form.get(field) as string | null); + if (v !== undefined) payload[key] = v; + } + + // Optional enum selects (non-empty string = value chosen) + const optEnums: Array<[string, string]> = [ + ['texture', 'texture'], + ['absorption_speed', 'absorption_speed'], + ['price_tier', 'price_tier'], + ]; + for (const [field, key] of optEnums) { + const v = form.get(field) as string | null; + if (v) payload[key] = v; + } + + // Optional numbers + const size_ml = parseOptionalFloat(form.get('size_ml') as string | null); + if (size_ml !== undefined) payload.size_ml = size_ml; + + const pao_months = parseOptionalInt(form.get('pao_months') as string | null); + if (pao_months !== undefined) payload.pao_months = pao_months; + + const ph_min = parseOptionalFloat(form.get('ph_min') as string | null); + if (ph_min !== undefined) payload.ph_min = ph_min; + + const ph_max = parseOptionalFloat(form.get('ph_max') as string | null); + if (ph_max !== undefined) payload.ph_max = ph_max; + + const min_interval_hours = parseOptionalInt(form.get('min_interval_hours') as string | null); + if (min_interval_hours !== undefined) payload.min_interval_hours = min_interval_hours; + + const max_frequency_per_week = parseOptionalInt(form.get('max_frequency_per_week') as string | null); + if (max_frequency_per_week !== undefined) payload.max_frequency_per_week = max_frequency_per_week; + + const personal_rating = parseOptionalInt(form.get('personal_rating') as string | null); + if (personal_rating !== undefined) payload.personal_rating = personal_rating; + + const needle_length_mm = parseOptionalFloat(form.get('needle_length_mm') as string | null); + if (needle_length_mm !== undefined) payload.needle_length_mm = needle_length_mm; + + // Booleans from checkboxes (unchecked = not sent = false) + payload.is_medication = form.get('is_medication') === 'true'; + payload.is_tool = form.get('is_tool') === 'true'; + + // Nullable booleans (tristate) + const fragranceFree = parseTristate(form.get('fragrance_free') as string | null); + if (fragranceFree !== undefined) payload.fragrance_free = fragranceFree; + + const essentialOilsFree = parseTristate(form.get('essential_oils_free') as string | null); + if (essentialOilsFree !== undefined) payload.essential_oils_free = essentialOilsFree; + + const alcoholDenatFree = parseTristate(form.get('alcohol_denat_free') as string | null); + if (alcoholDenatFree !== undefined) payload.alcohol_denat_free = alcoholDenatFree; + + const pregnancySafe = parseTristate(form.get('pregnancy_safe') as string | null); + if (pregnancySafe !== undefined) payload.pregnancy_safe = pregnancySafe; + + const personalRepurchaseIntent = parseTristate( + form.get('personal_repurchase_intent') as string | null + ); + if (personalRepurchaseIntent !== undefined) + payload.personal_repurchase_intent = personalRepurchaseIntent; let product; try { - product = await createProduct({ - name, - brand, - category, - recommended_time, - routine_role, - leave_on, - targets - }); + product = await createProduct(payload); } catch (e) { return fail(500, { error: (e as Error).message }); } diff --git a/frontend/src/routes/products/new/+page.svelte b/frontend/src/routes/products/new/+page.svelte index 0eeb394..851b13e 100644 --- a/frontend/src/routes/products/new/+page.svelte +++ b/frontend/src/routes/products/new/+page.svelte @@ -9,29 +9,47 @@ let { form }: { form: ActionData } = $props(); + // ── enum options ────────────────────────────────────────────────────────── const categories = [ 'cleanser', 'toner', 'essence', 'serum', 'moisturizer', 'spf', 'mask', 'exfoliant', 'hair_treatment', 'tool', 'spot_treatment', 'oil' ]; - const routineRoles = [ - 'cleanse', 'prepare', 'treatment_active', 'treatment_support', - 'seal', 'protect', 'hair_treatment' - ]; + const textures = ['watery', 'gel', 'emulsion', 'cream', 'oil', 'balm', 'foam', 'fluid']; + const absorptionSpeeds = ['very_fast', 'fast', 'moderate', 'slow', 'very_slow']; + const priceTiers = ['budget', 'mid', 'premium', 'luxury']; + const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone']; const skinConcerns = [ 'acne', 'rosacea', 'hyperpigmentation', 'aging', 'dehydration', 'redness', 'damaged_barrier', 'pore_visibility', 'uneven_texture', 'hair_growth', 'sebum_excess' ]; + const tristateOptions = [ + { value: '', label: 'Unknown' }, + { value: 'true', label: 'Yes' }, + { value: 'false', label: 'No' } + ]; + // ── controlled select state ─────────────────────────────────────────────── let category = $state(''); - let routineRole = $state(''); let recommendedTime = $state(''); let leaveOn = $state('true'); + let texture = $state(''); + let absorptionSpeed = $state(''); + let priceTier = $state(''); + let fragranceFree = $state(''); + let essentialOilsFree = $state(''); + let alcoholDenatFree = $state(''); + let pregnancySafe = $state(''); + let personalRepurchaseIntent = $state(''); + + function label(val: string) { + return val.replace(/_/g, ' '); + } New Product — innercontext -
+
← Products

New Product

@@ -43,85 +61,348 @@
{/if} - - Product details - -
-
- - + + + + + Basic info + +
+
+ + +
+
+ + +
-
- - +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + + + + + Classification +
+
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+
+
- - - +
+
+
+ + + Skin profile +
- - - + +
+ {#each skinTypes as st} + + {/each} +
- - - + +
+ {#each skinConcerns as sc} + + {/each} +
+
+
+
+ + + + Product details + +
+
+ + + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + Safety flags + +
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+
+
+
+ + + + Ingredients & notes + +
+ +
- - + + +
+
+
+ + + + Usage constraints + +
+
+ + +
+
+ + +
-
- - +
+ +
- - - + +
+ + +
+ + + + + + Personal notes + +
+
+ + +
+ +
+ + + +
+
+ +
+ + +
+
+
+ +
+ + +
+