diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index 4322132..ad52e8c 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -1,4 +1,5 @@ import json +import math from datetime import date, timedelta from typing import Optional from uuid import UUID, uuid4 @@ -306,10 +307,9 @@ def _build_recent_history(session: Session) -> str: def _build_products_context( session: Session, - time_filter: Optional[str] = None, + products: list[Product], reference_date: Optional[date] = None, ) -> str: - products = _get_available_products(session, time_filter=time_filter) product_ids = [p.id for p in products] inventory_rows = ( session.exec( @@ -400,6 +400,7 @@ def _build_products_context( def _get_available_products( session: Session, time_filter: Optional[str] = None, + include_minoxidil: bool = True, ) -> list[Product]: stmt = select(Product).where(col(Product.is_tool).is_(False)) products = session.exec(stmt).all() @@ -407,12 +408,32 @@ def _get_available_products( for p in products: if p.is_medication and not _is_minoxidil_product(p): continue + if not include_minoxidil and _is_minoxidil_product(p): + continue if time_filter and _ev(p.recommended_time) not in (time_filter, "both"): continue result.append(p) return result +def _filter_products_by_interval( + products: list[Product], + routine_date: date, + last_used_on_by_product: dict[str, date], +) -> list[Product]: + """Remove products that haven't yet reached their min_interval_hours since last use.""" + result = [] + for p in products: + if p.min_interval_hours: + last_used = last_used_on_by_product.get(str(p.id)) + if last_used is not None: + days_needed = math.ceil(p.min_interval_hours / 24) + if routine_date < last_used + timedelta(days=days_needed): + continue + result.append(p) + return result + + def _extract_active_names(product: Product) -> list[str]: names: list[str] = [] for a in product.actives or []: @@ -596,17 +617,23 @@ def suggest_routine( grooming_ctx = _build_grooming_context(session, weekdays=[weekday]) history_ctx = _build_recent_history(session) day_ctx = _build_day_context(data.leaving_home) - products_ctx = _build_products_context( - session, time_filter=data.part_of_day.value, reference_date=data.routine_date - ) available_products = _get_available_products( session, time_filter=data.part_of_day.value, + include_minoxidil=data.include_minoxidil_beard, ) last_used_on_by_product = build_last_used_on_by_product( session, product_ids=[p.id for p in available_products], ) + available_products = _filter_products_by_interval( + available_products, + data.routine_date, + last_used_on_by_product, + ) + products_ctx = _build_products_context( + session, available_products, reference_date=data.routine_date + ) objectives_ctx = _build_objectives_context(data.include_minoxidil_beard) mode_line = "MODE: standard" @@ -762,7 +789,13 @@ def suggest_batch( skin_ctx = _build_skin_context(session) grooming_ctx = _build_grooming_context(session, weekdays=weekdays) history_ctx = _build_recent_history(session) - products_ctx = _build_products_context(session, reference_date=data.from_date) + batch_products = _get_available_products( + session, + include_minoxidil=data.include_minoxidil_beard, + ) + products_ctx = _build_products_context( + session, batch_products, reference_date=data.from_date + ) objectives_ctx = _build_objectives_context(data.include_minoxidil_beard) date_range_lines = [] diff --git a/backend/tests/test_routines_helpers.py b/backend/tests/test_routines_helpers.py index d37326a..40cce52 100644 --- a/backend/tests/test_routines_helpers.py +++ b/backend/tests/test_routines_helpers.py @@ -15,6 +15,7 @@ from innercontext.api.routines import ( _ev, _extract_active_names, _extract_requested_product_ids, + _filter_products_by_interval, _get_available_products, _is_minoxidil_product, ) @@ -204,9 +205,8 @@ def test_build_products_context(session: Session): session.add(s) session.commit() - ctx = _build_products_context( - session, time_filter="am", reference_date=date.today() - ) + products_am = _get_available_products(session, time_filter="am") + ctx = _build_products_context(session, products_am, reference_date=date.today()) # p1 is medication but not minoxidil (wait, Regaine name doesn't contain minoxidil!) -> skipped assert "Regaine" not in ctx @@ -215,9 +215,8 @@ def test_build_products_context(session: Session): session.add(p1) session.commit() - ctx = _build_products_context( - session, time_filter="am", reference_date=date.today() - ) + products_am = _get_available_products(session, time_filter="am") + ctx = _build_products_context(session, products_am, reference_date=date.today()) assert "Regaine Minoxidil" in ctx assert "Sunscreen" in ctx assert "inventory_status={active:2,opened:1,sealed:1}" in ctx @@ -375,6 +374,106 @@ def test_extract_active_names_uses_compact_distinct_names(session: Session): assert names == ["Niacinamide", "Zinc PCA"] +def test_get_available_products_excludes_minoxidil_when_flag_false(session: Session): + minoxidil = Product( + id=uuid.uuid4(), + name="Minoxidil 5%", + category="hair_treatment", + is_medication=True, + brand="Test", + recommended_time="both", + leave_on=True, + product_effect_profile={}, + ) + regular = Product( + id=uuid.uuid4(), + name="Cleanser", + category="cleanser", + brand="Test", + recommended_time="both", + leave_on=False, + product_effect_profile={}, + ) + session.add_all([minoxidil, regular]) + session.commit() + + # With flag True (default) - minoxidil included + products = _get_available_products(session, include_minoxidil=True) + names = {p.name for p in products} + assert "Minoxidil 5%" in names + assert "Cleanser" in names + + # With flag False - minoxidil excluded + products = _get_available_products(session, include_minoxidil=False) + names = {p.name for p in products} + assert "Minoxidil 5%" not in names + assert "Cleanser" in names + + +def test_filter_products_by_interval(): + today = date.today() + + p_no_interval = Product( + id=uuid.uuid4(), + name="No Interval", + category="serum", + brand="Test", + recommended_time="both", + leave_on=True, + product_effect_profile={}, + ) + p_interval_72 = Product( + id=uuid.uuid4(), + name="Retinol", + category="serum", + brand="Test", + recommended_time="pm", + leave_on=True, + min_interval_hours=72, + product_effect_profile={}, + ) + p_interval_48 = Product( + id=uuid.uuid4(), + name="AHA", + category="exfoliant", + brand="Test", + recommended_time="pm", + leave_on=True, + min_interval_hours=48, + product_effect_profile={}, + ) + + last_used = { + str(p_interval_72.id): today - timedelta(days=1), # used yesterday -> need 3 days + str(p_interval_48.id): today - timedelta(days=3), # used 3 days ago -> 48h ok + } + + products = [p_no_interval, p_interval_72, p_interval_48] + result = _filter_products_by_interval(products, today, last_used) + result_names = {p.name for p in result} + + assert "No Interval" in result_names # always included + assert "Retinol" not in result_names # used 1 day ago, needs 3 -> blocked + assert "AHA" in result_names # used 3 days ago, needs 2 -> ok + + +def test_filter_products_by_interval_never_used_passes(): + today = date.today() + p = Product( + id=uuid.uuid4(), + name="Retinol", + category="serum", + brand="Test", + recommended_time="pm", + leave_on=True, + min_interval_hours=72, + product_effect_profile={}, + ) + # no last_used entry -> should pass + result = _filter_products_by_interval([p], today, {}) + assert len(result) == 1 + + def test_product_details_tool_handler_returns_product_payloads(session: Session): p = Product( id=uuid.uuid4(),