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
|
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>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user