From ae4186d9fa3334e3472066d48542b6a618391f1b Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Wed, 20 May 2026 23:07:02 +0200 Subject: [PATCH] Collapse PlanEntry customization into a materialized ingredient snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlanCustomization / IngredientCustomization / AddedIngredient disappear; PlanEntry now carries List directly. Substitutions, exclusions, amount overrides, product picks, and added ingredients are all just whatever ends up in the list. Recipe edits no longer mutate historic plan entries — load-bearing once consumption tracking lands. Co-Authored-By: Claude Opus 4.7 --- .../recipe/shared/domain/model/Planner.kt | 48 +++----- .../model/SerializationRoundTripTest.kt | 106 +++++++++--------- 2 files changed, 68 insertions(+), 86 deletions(-) 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 ──────────────────────────────────────────────────────────