Collapse PlanEntry customization into a materialized ingredient snapshot

PlanCustomization / IngredientCustomization / AddedIngredient disappear;
PlanEntry now carries List<PlanIngredient> 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 23:07:02 +02:00
parent 2d2556fd26
commit ae4186d9fa
2 changed files with 68 additions and 86 deletions

View File

@@ -4,50 +4,34 @@ import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
* Per-ingredient overrides applied to a planned meal. All fields are optional; * One ingredient line on a planned meal. Self-contained — substitutions,
* an entry is only stored in [PlanCustomization.overrides] when at least one * exclusions, amount overrides, and product picks are not modeled as
* field is non-default. The repository prunes default-only entries on write. * overlays on the recipe; they're just whatever ends up in this list.
* *
* The four fields cover PLAN-07 through PLAN-11 (substitutions, exclusions, * A swap (butter → margarine) is just a `PlanIngredient` with the swapped
* amount overrides, product picks) collapsed onto a single per-ingredient row * `ingredientId`. A removal is absence from the list. An amount tweak is
* so they can compose — substituting almonds for walnuts AND overriding the * the [quantity] field. A pinned brand is [productPick].
* amount AND pinning a brand all land on the same record.
*/ */
@Serializable @Serializable
public data class IngredientCustomization( public data class PlanIngredient(
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 val ingredientId: IngredientId, public val ingredientId: IngredientId,
public val quantity: Quantity, public val quantity: Quantity,
public val productPick: ProductId? = null, public val productPick: ProductId? = null,
) )
/** /**
* All customizations attached to a single [PlanEntry]. Defaults are empty — * One planned meal: a recipe at a date/slot pair, with a fully-materialized
* a Phase 6 entry without customizations stays cheap. * snapshot of the ingredients as the user planned them. Identity is the UUID
*/
@Serializable
public data class PlanCustomization(
public val overrides: Map<IngredientId, IngredientCustomization> = emptyMap(),
public val added: List<AddedIngredient> = emptyList(),
)
/**
* One planned meal: a recipe at a date/slot pair. Identity is the UUID
* [PlanEntryId] — never the composite `(date, slot)` — so concurrent edits * [PlanEntryId] — never the composite `(date, slot)` — so concurrent edits
* across devices can survive a delete + recreate race without colliding on a * across devices can survive a delete + recreate race without colliding on a
* natural key (PLAN-14). * 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 * "Skipped slot" (PLAN-12) is modeled as absence — no [PlanEntry] row exists
* for that `(date, slotId)`. No sentinel field, no sealed sibling. * for that `(date, slotId)`. No sentinel field, no sealed sibling.
*/ */
@@ -59,5 +43,5 @@ public data class PlanEntry(
public val slotId: MealSlotId, public val slotId: MealSlotId,
public val recipeId: RecipeId, public val recipeId: RecipeId,
public val servings: Double, public val servings: Double,
public val customization: PlanCustomization = PlanCustomization(), public val ingredients: List<PlanIngredient>,
) )

View File

@@ -94,13 +94,20 @@ class SerializationRoundTripTest {
// ── PlanEntry — the full customization shape ───────────────────────── // ── PlanEntry — the full customization shape ─────────────────────────
@Test @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 flour = IngredientId("ing_flour")
val butter = IngredientId("ing_butter")
val margarine = IngredientId("ing_margarine") val margarine = IngredientId("ing_margarine")
val salt = IngredientId("ing_salt")
val oil = IngredientId("ing_oil")
val sugar = IngredientId("ing_sugar") val sugar = IngredientId("ing_sugar")
val oil = IngredientId("ing_oil")
val piatnica = ProductId("prd_piatnica_serek") val piatnica = ProductId("prd_piatnica_serek")
val entry = val entry =
@@ -117,39 +124,35 @@ class SerializationRoundTripTest {
slotId = MealSlotId("slot_breakfast"), slotId = MealSlotId("slot_breakfast"),
recipeId = RecipeId("rcp_pancakes"), recipeId = RecipeId("rcp_pancakes"),
servings = 2.0, servings = 2.0,
customization = ingredients =
PlanCustomization( listOf(
overrides = PlanIngredient(
mapOf( ingredientId = flour,
// Substitute butter → margarine quantity =
butter to IngredientCustomization(substituteWith = margarine), Quantity(
// Exclude salt entirely amount = 250.0,
salt to IngredientCustomization(excluded = true), unit = MeasurementUnit.GRAM,
// Override flour to 250g (use displayHint to prove it round-trips) displayHint = mapOf(pl to "2 szklanki"),
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"),
),
), ),
), ),
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). // (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("\"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("\"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 @Test
fun `empty PlanCustomization omits with encodeDefaults off`() { fun `PlanIngredient without productPick omits the null field`() {
val empty = PlanCustomization() val ingredient =
val encoded = json.encodeToString(PlanCustomization.serializer(), empty) 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 assertEquals(
// defaults and the wire payload is `{}`. Phase 6 PlanEntry rows without "{\"ingredientId\":\"ing_flour\",\"quantity\":{\"amount\":100.0,\"unit\":\"gram\"}}",
// customizations get the cheapest possible JSONB representation. encoded,
assertEquals("{}", encoded) )
assertEquals(empty, json.decodeFromString(PlanCustomization.serializer(), encoded)) assertEquals(ingredient, json.decodeFromString(PlanIngredient.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)
} }
// ── Catalog ────────────────────────────────────────────────────────── // ── Catalog ──────────────────────────────────────────────────────────