diff --git a/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Planner.kt b/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Planner.kt index b781570..13c5742 100644 --- a/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Planner.kt +++ b/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Planner.kt @@ -4,50 +4,34 @@ import kotlinx.datetime.LocalDate import kotlinx.serialization.Serializable /** - * Per-ingredient overrides applied to a planned meal. All fields are optional; - * an entry is only stored in [PlanCustomization.overrides] when at least one - * field is non-default. The repository prunes default-only entries on write. + * One ingredient line on a planned meal. Self-contained — substitutions, + * exclusions, amount overrides, and product picks are not modeled as + * overlays on the recipe; they're just whatever ends up in this list. * - * The four fields cover PLAN-07 through PLAN-11 (substitutions, exclusions, - * amount overrides, product picks) collapsed onto a single per-ingredient row - * so they can compose — substituting almonds for walnuts AND overriding the - * amount AND pinning a brand all land on the same record. + * A swap (butter → margarine) is just a `PlanIngredient` with the swapped + * `ingredientId`. A removal is absence from the list. An amount tweak is + * the [quantity] field. A pinned brand is [productPick]. */ @Serializable -public data class IngredientCustomization( - public val substituteWith: IngredientId? = null, - public val excluded: Boolean = false, - public val amountOverride: Quantity? = null, - public val productPick: ProductId? = null, -) - -/** - * An ingredient injected into a planned meal that isn't part of the original - * recipe (PLAN-10). [productPick] optionally pins a specific [Product]. - */ -@Serializable -public data class AddedIngredient( +public data class PlanIngredient( public val ingredientId: IngredientId, public val quantity: Quantity, public val productPick: ProductId? = null, ) /** - * All customizations attached to a single [PlanEntry]. Defaults are empty — - * a Phase 6 entry without customizations stays cheap. - */ -@Serializable -public data class PlanCustomization( - public val overrides: Map = emptyMap(), - public val added: List = emptyList(), -) - -/** - * One planned meal: a recipe at a date/slot pair. Identity is the UUID + * One planned meal: a recipe at a date/slot pair, with a fully-materialized + * snapshot of the ingredients as the user planned them. Identity is the UUID * [PlanEntryId] — never the composite `(date, slot)` — so concurrent edits * across devices can survive a delete + recreate race without colliding on a * natural key (PLAN-14). * + * [recipeId] is provenance only: it powers the "open recipe" link, the "cook + * again" action, and analytics. The recipe's current ingredient list is not + * consulted at read time — [ingredients] is the source of truth for this + * meal. This way, editing a recipe never mutates historic plan entries + * (load-bearing once consumption tracking lands in a later phase). + * * "Skipped slot" (PLAN-12) is modeled as absence — no [PlanEntry] row exists * for that `(date, slotId)`. No sentinel field, no sealed sibling. */ @@ -59,5 +43,5 @@ public data class PlanEntry( public val slotId: MealSlotId, public val recipeId: RecipeId, public val servings: Double, - public val customization: PlanCustomization = PlanCustomization(), + public val ingredients: List, ) diff --git a/shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/domain/model/SerializationRoundTripTest.kt b/shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/domain/model/SerializationRoundTripTest.kt index 777589b..b19539b 100644 --- a/shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/domain/model/SerializationRoundTripTest.kt +++ b/shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/domain/model/SerializationRoundTripTest.kt @@ -94,13 +94,20 @@ class SerializationRoundTripTest { // ── PlanEntry — the full customization shape ───────────────────────── @Test - fun `PlanEntry with every customization kind round trips`() { + fun `PlanEntry carries a materialized ingredient snapshot and round trips`() { + // The snapshot model collapses substitutions, exclusions, amount overrides, + // product picks, and added ingredients into a single ingredient list. The + // recipe's original list is never consulted at read time, so each of these + // "customization kinds" is just whatever ends up here: + // - butter → margarine swap: margarine appears, butter doesn't + // - salt excluded: salt simply isn't in the list + // - flour amount override: the quantity here IS the amount + // - sugar with pinned product: productPick set on the sugar row + // - oil added (not in the recipe): just another row val flour = IngredientId("ing_flour") - val butter = IngredientId("ing_butter") val margarine = IngredientId("ing_margarine") - val salt = IngredientId("ing_salt") - val oil = IngredientId("ing_oil") val sugar = IngredientId("ing_sugar") + val oil = IngredientId("ing_oil") val piatnica = ProductId("prd_piatnica_serek") val entry = @@ -117,39 +124,35 @@ class SerializationRoundTripTest { slotId = MealSlotId("slot_breakfast"), recipeId = RecipeId("rcp_pancakes"), servings = 2.0, - customization = - PlanCustomization( - overrides = - mapOf( - // Substitute butter → margarine - butter to IngredientCustomization(substituteWith = margarine), - // Exclude salt entirely - salt to IngredientCustomization(excluded = true), - // Override flour to 250g (use displayHint to prove it round-trips) - flour to - IngredientCustomization( - amountOverride = - Quantity( - amount = 250.0, - unit = MeasurementUnit.GRAM, - displayHint = mapOf(pl to "2 szklanki"), - ), - ), - // Pin a specific product - sugar to IngredientCustomization(productPick = piatnica), - ), - added = - listOf( - AddedIngredient( - ingredientId = oil, - quantity = - Quantity( - amount = 15.0, - unit = MeasurementUnit.MILLILITER, - displayHint = mapOf(pl to "1 łyżka"), - ), + ingredients = + listOf( + PlanIngredient( + ingredientId = flour, + quantity = + Quantity( + amount = 250.0, + unit = MeasurementUnit.GRAM, + displayHint = mapOf(pl to "2 szklanki"), ), - ), + ), + PlanIngredient( + ingredientId = margarine, + quantity = Quantity(amount = 50.0, unit = MeasurementUnit.GRAM), + ), + PlanIngredient( + ingredientId = sugar, + quantity = Quantity(amount = 30.0, unit = MeasurementUnit.GRAM), + productPick = piatnica, + ), + PlanIngredient( + ingredientId = oil, + quantity = + Quantity( + amount = 15.0, + unit = MeasurementUnit.MILLILITER, + displayHint = mapOf(pl to "1 łyżka"), + ), + ), ), ) @@ -162,28 +165,23 @@ class SerializationRoundTripTest { // (otherwise the JSONB column gets ugly and queries are unportable). assertTrue(encoded.contains("\"recipeId\":\"rcp_pancakes\""), "recipeId should serialize as a bare string: $encoded") assertTrue(encoded.contains("\"slotId\":\"slot_breakfast\""), "slotId should serialize as a bare string: $encoded") + assertTrue(encoded.contains("\"productPick\":\"prd_piatnica_serek\""), "productPick should serialize as a bare string: $encoded") } @Test - fun `empty PlanCustomization omits with encodeDefaults off`() { - val empty = PlanCustomization() - val encoded = json.encodeToString(PlanCustomization.serializer(), empty) + fun `PlanIngredient without productPick omits the null field`() { + val ingredient = + PlanIngredient( + ingredientId = IngredientId("ing_flour"), + quantity = Quantity(amount = 100.0, unit = MeasurementUnit.GRAM), + ) + val encoded = json.encodeToString(PlanIngredient.serializer(), ingredient) - // With encodeDefaults = false, both fields fall back to their empty - // defaults and the wire payload is `{}`. Phase 6 PlanEntry rows without - // customizations get the cheapest possible JSONB representation. - assertEquals("{}", encoded) - assertEquals(empty, json.decodeFromString(PlanCustomization.serializer(), encoded)) - } - - @Test - fun `IngredientCustomization with all defaults omits every field`() { - val empty = IngredientCustomization() - val encoded = json.encodeToString(IngredientCustomization.serializer(), empty) - - // Repository contract: prune fully-default entries before write — but - // if one slips through, the wire representation is still empty. - assertEquals("{}", encoded) + assertEquals( + "{\"ingredientId\":\"ing_flour\",\"quantity\":{\"amount\":100.0,\"unit\":\"gram\"}}", + encoded, + ) + assertEquals(ingredient, json.decodeFromString(PlanIngredient.serializer(), encoded)) } // ── Catalog ──────────────────────────────────────────────────────────