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:
@@ -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<IngredientId, IngredientCustomization> = emptyMap(),
|
||||
public val added: List<AddedIngredient> = 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<PlanIngredient>,
|
||||
)
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user